From: Jeff Halter Date: Sat, 15 Jun 2024 18:13:16 +0000 (-0700) Subject: Refactoring and cleanup X-Git-Url: https://git.r.bdr.sh/rbdr/mobius/commitdiff_plain/95159e5585762c06c654945070ba54262b7dcec9?ds=inline Refactoring and cleanup * Split CLI client into separate project * Convert more functions to follow common Golang idioms e.g io.Reader, io.Writer * Use ldflags for versioning * Misc cleanup and simplification --- diff --git a/cmd/mobius-hotline-client/main.go b/cmd/mobius-hotline-client/main.go deleted file mode 100644 index 20bd7f2..0000000 --- a/cmd/mobius-hotline-client/main.go +++ /dev/null @@ -1,87 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "github.com/jhalter/mobius/hotline" - "github.com/rivo/tview" - "log/slog" - "os" - "os/signal" - "runtime" - "syscall" -) - -var logLevels = map[string]slog.Level{ - "debug": slog.LevelDebug, - "info": slog.LevelInfo, - "warn": slog.LevelWarn, - "error": slog.LevelError, -} - -func main() { - _, cancelRoot := context.WithCancel(context.Background()) - - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT, os.Interrupt) - - configDir := flag.String("config", defaultConfigPath(), "Path to config root") - version := flag.Bool("version", false, "print version and exit") - logLevel := flag.String("log-level", "info", "Log level") - logFile := flag.String("log-file", "", "output logs to file") - - flag.Parse() - - if *version { - fmt.Printf("v%s\n", hotline.VERSION) - os.Exit(0) - } - - // init DebugBuffer - db := &hotline.DebugBuffer{ - TextView: tview.NewTextView(), - } - - // Add file logger if optional log-file flag was passed - if *logFile != "" { - f, err := os.OpenFile(*logFile, - os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - defer func() { _ = f.Close() }() - if err != nil { - panic(err) - } - } - - logger := slog.New(slog.NewTextHandler(db, &slog.HandlerOptions{Level: logLevels[*logLevel]})) - logger.Info("Started Mobius client", "Version", hotline.VERSION) - - go func() { - sig := <-sigChan - logger.Info("Stopping client", "signal", sig.String()) - cancelRoot() - }() - - client := hotline.NewUIClient(*configDir, logger) - client.DebugBuf = db - client.UI.Start() -} - -func defaultConfigPath() (cfgPath string) { - switch runtime.GOOS { - case "windows": - cfgPath = "mobius-client-config.yaml" - case "darwin": - if _, err := os.Stat("/usr/local/etc/mobius-client-config.yaml"); err == nil { - cfgPath = "/usr/local/etc/mobius-client-config.yaml" - } else if _, err := os.Stat("/opt/homebrew/etc/mobius-client-config.yaml"); err == nil { - cfgPath = "/opt/homebrew/etc/mobius-client-config.yaml" - } - case "linux": - cfgPath = "/usr/local/etc/mobius-client-config.yaml" - default: - fmt.Printf("unsupported OS") - } - - return cfgPath -} diff --git a/cmd/mobius-hotline-client/mobius-client-config.yaml b/cmd/mobius-hotline-client/mobius-client-config.yaml deleted file mode 100644 index b1edb40..0000000 --- a/cmd/mobius-hotline-client/mobius-client-config.yaml +++ /dev/null @@ -1,8 +0,0 @@ -Username: unnamed -IconID: 414 -Tracker: hltracker.com:5498 -Bookmarks: - - Name: The Mobius Strip - Addr: trtphotl.com:5500 - Login: guest - Password: "" \ No newline at end of file diff --git a/cmd/mobius-hotline-server/main.go b/cmd/mobius-hotline-server/main.go index c027d18..8e66938 100644 --- a/cmd/mobius-hotline-server/main.go +++ b/cmd/mobius-hotline-server/main.go @@ -28,6 +28,12 @@ var logLevels = map[string]slog.Level{ "error": slog.LevelError, } +var ( + version = "dev" + commit = "none" + date = "unknown" +) + func main() { ctx, cancel := context.WithCancel(context.Background()) @@ -50,7 +56,7 @@ func main() { basePort := flag.Int("bind", defaultPort, "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") - version := flag.Bool("version", false, "print version and exit") + 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") @@ -58,8 +64,8 @@ func main() { flag.Parse() - if *version { - fmt.Printf("v%s\n", hotline.VERSION) + if *printVersion { + fmt.Printf("mobius-hotline-server %s, commit %s, built at %s", version, commit, date) os.Exit(0) } diff --git a/go.mod b/go.mod index 868d867..5fe9eaf 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,8 @@ module github.com/jhalter/mobius go 1.22 require ( - github.com/gdamore/tcell/v2 v2.7.4 + github.com/davecgh/go-spew v1.1.1 github.com/go-playground/validator/v10 v10.19.0 - github.com/rivo/tview v0.0.0-20240413115534-b0d41c484b95 github.com/stretchr/testify v1.9.0 golang.org/x/crypto v0.22.0 golang.org/x/text v0.14.0 @@ -14,18 +13,12 @@ require ( ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect - github.com/gdamore/encoding v1.0.1 // 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/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rivo/uniseg v0.4.7 // 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/term v0.19.0 // indirect ) diff --git a/go.sum b/go.sum index 8983629..a7c6080 100644 --- a/go.sum +++ b/go.sum @@ -2,11 +2,6 @@ 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/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= -github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= -github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU= -github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= 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= @@ -17,64 +12,20 @@ github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn github.com/go-playground/validator/v10 v10.19.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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/tview v0.0.0-20240413115534-b0d41c484b95 h1:dPivHKc1ZAicSlawH/eAmGPSCfOuCYRQLl+Eq1eRKNU= -github.com/rivo/tview v0.0.0-20240413115534-b0d41c484b95/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= -golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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/account.go b/hotline/account.go index 4c5a9b9..3ad0687 100644 --- a/hotline/account.go +++ b/hotline/account.go @@ -2,6 +2,7 @@ package hotline import ( "encoding/binary" + "fmt" "golang.org/x/crypto/bcrypt" "io" "log" @@ -15,10 +16,12 @@ type Account struct { Name string `yaml:"Name"` Password string `yaml:"Password"` Access accessBitmap `yaml:"Access"` + + readOffset int // Internal offset to track read progress } // Read implements io.Reader interface for Account -func (a *Account) Read(p []byte) (n int, err error) { +func (a *Account) Read(p []byte) (int, error) { fields := []Field{ NewField(FieldUserName, []byte(a.Name)), NewField(FieldUserLogin, encodeString([]byte(a.Login))), @@ -34,10 +37,22 @@ func (a *Account) Read(p []byte) (n int, err error) { var fieldBytes []byte for _, field := range fields { - fieldBytes = append(fieldBytes, field.Payload()...) + b, err := io.ReadAll(&field) + if err != nil { + return 0, fmt.Errorf("error reading field: %w", err) + } + fieldBytes = append(fieldBytes, b...) + } + + buf := slices.Concat(fieldCount, fieldBytes) + if a.readOffset >= len(buf) { + return 0, io.EOF // All bytes have been read } - return copy(p, slices.Concat(fieldCount, fieldBytes)), io.EOF + n := copy(p, buf[a.readOffset:]) + a.readOffset += n + + return n, nil } // hashAndSalt generates a password hash from a users obfuscated plaintext password diff --git a/hotline/banners/1.txt b/hotline/banners/1.txt deleted file mode 100644 index 608a316..0000000 --- a/hotline/banners/1.txt +++ /dev/null @@ -1,6 +0,0 @@ - __ __ ______ ______ __ __ __ __ ______ -/\ \_\ \ /\ __ \ /\__ _\ /\ \ /\ \ /\ "-.\ \ /\ ___\ -\ \ __ \ \ \ \/\ \ \/_/\ \/ \ \ \____ \ \ \ \ \ \-. \ \ \ __\ - \ \_\ \_\ \ \_____\ \ \_\ \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ - \/_/\/_/ \/_____/ \/_/ \/_____/ \/_/ \/_/ \/_/ \/_____/ - \ No newline at end of file diff --git a/hotline/banners/2.txt b/hotline/banners/2.txt deleted file mode 100644 index 1121e06..0000000 --- a/hotline/banners/2.txt +++ /dev/null @@ -1,9 +0,0 @@ - ▄█ █▄ ▄██████▄ ███ ▄█ ▄█ ███▄▄▄▄ ▄████████ - ███ ███ ███ ███ ▀█████████▄ ███ ███ ███▀▀▀██▄ ███ ███ - ███ ███ ███ ███ ▀███▀▀██ ███ ███▌ ███ ███ ███ █▀ - ▄███▄▄▄▄███▄▄ ███ ███ ███ ▀ ███ ███▌ ███ ███ ▄███▄▄▄ -▀▀███▀▀▀▀███▀ ███ ███ ███ ███ ███▌ ███ ███ ▀▀███▀▀▀ - ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ █▄ - ███ ███ ███ ███ ███ ███▌ ▄ ███ ███ ███ ███ ███ - ███ █▀ ▀██████▀ ▄████▀ █████▄▄██ █▀ ▀█ █▀ ██████████ - ▀ \ No newline at end of file diff --git a/hotline/banners/3.txt b/hotline/banners/3.txt deleted file mode 100644 index 608a316..0000000 --- a/hotline/banners/3.txt +++ /dev/null @@ -1,6 +0,0 @@ - __ __ ______ ______ __ __ __ __ ______ -/\ \_\ \ /\ __ \ /\__ _\ /\ \ /\ \ /\ "-.\ \ /\ ___\ -\ \ __ \ \ \ \/\ \ \/_/\ \/ \ \ \____ \ \ \ \ \ \-. \ \ \ __\ - \ \_\ \_\ \ \_____\ \ \_\ \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ - \/_/\/_/ \/_____/ \/_/ \/_____/ \/_/ \/_/ \/_/ \/_____/ - \ No newline at end of file diff --git a/hotline/banners/4.txt b/hotline/banners/4.txt deleted file mode 100644 index 3b9d720..0000000 --- a/hotline/banners/4.txt +++ /dev/null @@ -1,7 +0,0 @@ -.___.__  ._______  _____._.___    .___ .______  ._______ -:   |  \ : .___  \ \__ _:||   |   : __|:      \ : .____/ -|   :   || :   |  |  |  :||   |   | : ||       || : _/\  -|   .   ||     :  |  |   ||   |/\ |   ||   |   ||   /  \ -|___|   | \_. ___/   |   ||   /  \|   ||___|   ||_.: __/ -    |___|   :/       |___||______/|___|    |___|   :/    -    :                                        diff --git a/hotline/banners/5.txt b/hotline/banners/5.txt deleted file mode 100644 index b6654e7..0000000 --- a/hotline/banners/5.txt +++ /dev/null @@ -1,7 +0,0 @@ - ▄▀▀▄ ▄▄ ▄▀▀▀▀▄ ▄▀▀▀█▀▀▄ ▄▀▀▀▀▄ ▄▀▀█▀▄ ▄▀▀▄ ▀▄ ▄▀▀█▄▄▄▄ -█ █ ▄▀ █ █ █ █ ▐ █ █ █ █ █ █ █ █ █ ▐ ▄▀ ▐ -▐ █▄▄▄█ █ █ ▐ █ ▐ █ ▐ █ ▐ ▐ █ ▀█ █▄▄▄▄▄ - █ █ ▀▄ ▄▀ █ █ █ █ █ █ ▌ - ▄▀ ▄▀ ▀▀▀▀ ▄▀ ▄▀▄▄▄▄▄▄▀ ▄▀▀▀▀▀▄ ▄▀ █ ▄▀▄▄▄▄ - █ █ █ █ █ █ █ ▐ █ ▐ - ▐ ▐ ▐ ▐ ▐ ▐ ▐ ▐ \ No newline at end of file diff --git a/hotline/banners/6.txt b/hotline/banners/6.txt deleted file mode 100644 index c8ac7a6..0000000 --- a/hotline/banners/6.txt +++ /dev/null @@ -1,6 +0,0 @@ - :: .: ... :::::::::::: ::: ::::::. :::..,:::::: - ,;; ;;, .;;;;;;;. ;;;;;;;;'''' ;;; ;;;`;;;;, `;;;;;;;'''' -,[[[,,,[[[ ,[[ \[[, [[ [[[ [[[ [[[[[. '[[ [[cccc -"$$$"""$$$ $$$, $$$ $$ $$' $$$ $$$ "Y$c$$ $$"""" - 888 "88o"888,_ _,88P 88, o88oo,.__888 888 Y88 888oo,__ - MMM YMM "YMMMMMP" MMM """"YUMMMMMM MMM YM """"YUMMM \ No newline at end of file diff --git a/hotline/banners/7.txt b/hotline/banners/7.txt deleted file mode 100644 index c072e25..0000000 --- a/hotline/banners/7.txt +++ /dev/null @@ -1,7 +0,0 @@ -██╗ ██╗ ██████╗ ████████╗██╗ ██╗███╗ ██╗███████╗ -██║ ██║██╔═══██╗╚══██╔══╝██║ ██║████╗ ██║██╔════╝ -███████║██║ ██║ ██║ ██║ ██║██╔██╗ ██║█████╗ -██╔══██║██║ ██║ ██║ ██║ ██║██║╚██╗██║██╔══╝ -██║ ██║╚██████╔╝ ██║ ███████╗██║██║ ╚████║███████╗ -╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝ - diff --git a/hotline/banners/8.txt b/hotline/banners/8.txt deleted file mode 100644 index d75e153..0000000 --- a/hotline/banners/8.txt +++ /dev/null @@ -1,10 +0,0 @@ - ██░ ██ ▒█████ ▄▄▄█████▓ ██▓ ██▓ ███▄ █ ▓█████ -▓██░ ██▒▒██▒ ██▒▓ ██▒ ▓▒▓██▒ ▓██▒ ██ ▀█ █ ▓█ ▀ -▒██▀▀██░▒██░ ██▒▒ ▓██░ ▒░▒██░ ▒██▒▓██ ▀█ ██▒▒███ -░▓█ ░██ ▒██ ██░░ ▓██▓ ░ ▒██░ ░██░▓██▒ ▐▌██▒▒▓█ ▄ -░▓█▒░██▓░ ████▓▒░ ▒██▒ ░ ░██████▒░██░▒██░ ▓██░░▒████▒ - ▒ ░░▒░▒░ ▒░▒░▒░ ▒ ░░ ░ ▒░▓ ░░▓ ░ ▒░ ▒ ▒ ░░ ▒░ ░ - ▒ ░▒░ ░ ░ ▒ ▒░ ░ ░ ░ ▒ ░ ▒ ░░ ░░ ░ ▒░ ░ ░ ░ - ░ ░░ ░░ ░ ░ ▒ ░ ░ ░ ▒ ░ ░ ░ ░ ░ - ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ - diff --git a/hotline/banners/9.txt b/hotline/banners/9.txt deleted file mode 100644 index eba9134..0000000 --- a/hotline/banners/9.txt +++ /dev/null @@ -1,9 +0,0 @@ - █████ █████ █████ ████ ███ -░░███ ░░███ ░░███ ░░███ ░░░ - ░███ ░███ ██████ ███████ ░███ ████ ████████ ██████ - ░███████████ ███░░███░░░███░ ░███ ░░███ ░░███░░███ ███░░███ - ░███░░░░░███ ░███ ░███ ░███ ░███ ░███ ░███ ░███ ░███████ - ░███ ░███ ░███ ░███ ░███ ███ ░███ ░███ ░███ ░███ ░███░░░ - █████ █████░░██████ ░░█████ █████ █████ ████ █████░░██████ -░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░ ░░░░░ ░░░░░░ - diff --git a/hotline/client.go b/hotline/client.go index 3969e26..33dbd0d 100644 --- a/hotline/client.go +++ b/hotline/client.go @@ -4,43 +4,19 @@ import ( "bufio" "bytes" "context" - "embed" "encoding/binary" - "errors" "fmt" - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - "gopkg.in/yaml.v3" + "io" "log/slog" - "math/big" - "math/rand" "net" - "os" - "strings" "time" ) -const ( - trackerListPage = "trackerList" - serverUIPage = "serverUI" -) - -//go:embed banners/*.txt -var bannerDir embed.FS - -type Bookmark struct { - Name string `yaml:"Name"` - Addr string `yaml:"Addr"` - Login string `yaml:"Login"` - Password string `yaml:"Password"` -} - type ClientPrefs struct { - Username string `yaml:"Username"` - IconID int `yaml:"IconID"` - Bookmarks []Bookmark `yaml:"Bookmarks"` - Tracker string `yaml:"Tracker"` - EnableBell bool `yaml:"EnableBell"` + Username string `yaml:"Username"` + IconID int `yaml:"IconID"` + Tracker string `yaml:"Tracker"` + EnableBell bool `yaml:"EnableBell"` } func (cp *ClientPrefs) IconBytes() []byte { @@ -49,42 +25,12 @@ func (cp *ClientPrefs) IconBytes() []byte { return iconBytes } -func (cp *ClientPrefs) AddBookmark(name, addr, login, pass string) { - cp.Bookmarks = append(cp.Bookmarks, Bookmark{Addr: addr, Login: login, Password: pass}) -} - -func readConfig(cfgPath string) (*ClientPrefs, error) { - fh, err := os.Open(cfgPath) - if err != nil { - return nil, err - } - - prefs := ClientPrefs{} - decoder := yaml.NewDecoder(fh) - if err := decoder.Decode(&prefs); err != nil { - return nil, err - } - return &prefs, nil -} - type Client struct { - cfgPath string - DebugBuf *DebugBuffer Connection net.Conn - UserAccess []byte - filePath []string - UserList []User Logger *slog.Logger + Pref *ClientPrefs + Handlers map[uint16]ClientHandler activeTasks map[uint32]*Transaction - serverName string - - Pref *ClientPrefs - - Handlers map[uint16]ClientHandler - - UI *UI - - Inbox chan *Transaction } type ClientHandler func(context.Context, *Client, *Transaction) ([]Transaction, error) @@ -104,48 +50,6 @@ func NewClient(username string, logger *slog.Logger) *Client { return c } -func NewUIClient(cfgPath string, logger *slog.Logger) *Client { - c := &Client{ - cfgPath: cfgPath, - Logger: logger, - activeTasks: make(map[uint32]*Transaction), - Handlers: clientHandlers, - } - c.UI = NewUI(c) - - prefs, err := readConfig(cfgPath) - if err != nil { - logger.Error(fmt.Sprintf("unable to read config file %s\n", cfgPath)) - os.Exit(1) - } - c.Pref = prefs - - return c -} - -// DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger -type DebugBuffer struct { - TextView *tview.TextView -} - -func (db *DebugBuffer) Write(p []byte) (int, error) { - return db.TextView.Write(p) -} - -// Sync is a noop function that dataFile to satisfy the zapcore.WriteSyncer interface -func (db *DebugBuffer) Sync() error { - return nil -} - -func randomBanner() string { - rand.Seed(time.Now().UnixNano()) - - bannerFiles, _ := bannerDir.ReadDir("banners") - file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name()) - - return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file) -} - type ClientTransaction struct { Name string Handler func(*Client, *Transaction) ([]Transaction, error) @@ -159,354 +63,6 @@ type ClientTHandler interface { Handle(*Client, *Transaction) ([]Transaction, error) } -var clientHandlers = map[uint16]ClientHandler{ - TranChatMsg: handleClientChatMsg, - TranLogin: handleClientTranLogin, - TranShowAgreement: handleClientTranShowAgreement, - TranUserAccess: handleClientTranUserAccess, - TranGetUserNameList: handleClientGetUserNameList, - TranNotifyChangeUser: handleNotifyChangeUser, - TranNotifyDeleteUser: handleNotifyDeleteUser, - TranGetMsgs: handleGetMsgs, - TranGetFileNameList: handleGetFileNameList, - TranServerMsg: handleTranServerMsg, - TranKeepAlive: func(ctx context.Context, client *Client, transaction *Transaction) (t []Transaction, err error) { - return t, err - }, -} - -func handleTranServerMsg(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) { - now := time.Now().Format(time.RFC850) - - msg := strings.ReplaceAll(string(t.GetField(FieldData).Data), "\r", "\n") - msg += "\n\nAt " + now - title := fmt.Sprintf("| Private Message From: %s |", t.GetField(FieldUserName).Data) - - msgBox := tview.NewTextView().SetScrollable(true) - msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkSlateBlue) - msgBox.SetTitle(title).SetBorder(true) - msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyEscape: - c.UI.Pages.RemovePage("serverMsgModal" + now) - } - return event - }) - - centeredFlex := tview.NewFlex(). - AddItem(nil, 0, 1, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(nil, 0, 1, false). - AddItem(msgBox, 0, 2, true). - AddItem(nil, 0, 1, false), 0, 2, true). - AddItem(nil, 0, 1, false) - - c.UI.Pages.AddPage("serverMsgModal"+now, centeredFlex, true, true) - c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf? - - return res, err -} - -func (c *Client) showErrMsg(msg string) { - t := time.Now().Format(time.RFC850) - - title := "| Error |" - - msgBox := tview.NewTextView().SetScrollable(true) - msgBox.SetText(msg).SetBackgroundColor(tcell.ColorDarkRed) - msgBox.SetTitle(title).SetBorder(true) - msgBox.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyEscape: - c.UI.Pages.RemovePage("serverMsgModal" + t) - } - return event - }) - - centeredFlex := tview.NewFlex(). - AddItem(nil, 0, 1, false). - AddItem(tview.NewFlex().SetDirection(tview.FlexRow). - AddItem(nil, 0, 1, false). - AddItem(msgBox, 0, 2, true). - AddItem(nil, 0, 1, false), 0, 2, true). - AddItem(nil, 0, 1, false) - - c.UI.Pages.AddPage("serverMsgModal"+t, centeredFlex, true, true) - c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf? -} - -func handleGetFileNameList(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) { - if t.IsError() { - c.showErrMsg(string(t.GetField(FieldError).Data)) - return res, err - } - - fTree := tview.NewTreeView().SetTopLevel(1) - root := tview.NewTreeNode("Root") - fTree.SetRoot(root).SetCurrentNode(root) - fTree.SetBorder(true).SetTitle("| Files |") - fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyEscape: - c.UI.Pages.RemovePage("files") - c.filePath = []string{} - case tcell.KeyEnter: - selectedNode := fTree.GetCurrentNode() - - if selectedNode.GetText() == "<- Back" { - c.filePath = c.filePath[:len(c.filePath)-1] - f := NewField(FieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/"))) - - if err := c.UI.HLClient.Send(*NewTransaction(TranGetFileNameList, nil, f)); err != nil { - c.UI.HLClient.Logger.Error("err", "err", err) - } - return event - } - - entry := selectedNode.GetReference().(*FileNameWithInfo) - - if bytes.Equal(entry.Type[:], []byte("fldr")) { - c.Logger.Info("get new directory listing", "name", string(entry.name)) - - c.filePath = append(c.filePath, string(entry.name)) - f := NewField(FieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/"))) - - if err := c.UI.HLClient.Send(*NewTransaction(TranGetFileNameList, nil, f)); err != nil { - c.UI.HLClient.Logger.Error("err", "err", err) - } - } else { - // TODO: initiate file download - c.Logger.Info("download file", "name", string(entry.name)) - } - } - - return event - }) - - if len(c.filePath) > 0 { - node := tview.NewTreeNode("<- Back") - root.AddChild(node) - } - - for _, f := range t.Fields { - var fn FileNameWithInfo - _, err = fn.Write(f.Data) - if err != nil { - return nil, nil - } - - if bytes.Equal(fn.Type[:], []byte("fldr")) { - node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.name)) - node.SetReference(&fn) - root.AddChild(node) - } else { - size := binary.BigEndian.Uint32(fn.FileSize[:]) / 1024 - - node := tview.NewTreeNode(fmt.Sprintf(" %-40s %10v KB", fn.name, size)) - node.SetReference(&fn) - root.AddChild(node) - } - } - - centerFlex := tview.NewFlex(). - AddItem(nil, 0, 1, false). - AddItem(tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(nil, 0, 1, false). - AddItem(fTree, 20, 1, true). - AddItem(nil, 0, 1, false), 60, 1, true). - AddItem(nil, 0, 1, false) - - c.UI.Pages.AddPage("files", centerFlex, true, true) - c.UI.App.Draw() - - return res, err -} - -func handleGetMsgs(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) { - newsText := string(t.GetField(FieldData).Data) - newsText = strings.ReplaceAll(newsText, "\r", "\n") - - newsTextView := tview.NewTextView(). - SetText(newsText). - SetDoneFunc(func(key tcell.Key) { - c.UI.Pages.SwitchToPage(serverUIPage) - c.UI.App.SetFocus(c.UI.chatInput) - }) - newsTextView.SetBorder(true).SetTitle("News") - - c.UI.Pages.AddPage("news", newsTextView, true, true) - // c.UI.Pages.SwitchToPage("news") - // c.UI.App.SetFocus(newsTextView) - c.UI.App.Draw() - - return res, err -} - -func handleNotifyChangeUser(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) { - newUser := User{ - ID: t.GetField(FieldUserID).Data, - Name: string(t.GetField(FieldUserName).Data), - Icon: t.GetField(FieldUserIconID).Data, - Flags: t.GetField(FieldUserFlags).Data, - } - - // Possible cases: - // user is new to the server - // user is already on the server but has a new name - - var oldName string - var newUserList []User - updatedUser := false - for _, u := range c.UserList { - if bytes.Equal(newUser.ID, u.ID) { - oldName = u.Name - u.Name = newUser.Name - if u.Name != newUser.Name { - _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n") - } - updatedUser = true - } - newUserList = append(newUserList, u) - } - - if !updatedUser { - newUserList = append(newUserList, newUser) - } - - c.UserList = newUserList - - c.renderUserList() - - return res, err -} - -func handleNotifyDeleteUser(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) { - exitUser := t.GetField(FieldUserID).Data - - var newUserList []User - for _, u := range c.UserList { - if !bytes.Equal(exitUser, u.ID) { - newUserList = append(newUserList, u) - } - } - - c.UserList = newUserList - - c.renderUserList() - - return res, err -} - -func handleClientGetUserNameList(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) { - var users []User - for _, field := range t.Fields { - // The Hotline protocol docs say that ClientGetUserNameList should only return FieldUsernameWithInfo (300) - // fields, but shxd sneaks in FieldChatSubject (115) so it's important to filter explicitly for the expected - // field type. Probably a good idea to do everywhere. - if bytes.Equal(field.ID, []byte{0x01, 0x2c}) { - var user User - if _, err := user.Write(field.Data); err != nil { - return res, fmt.Errorf("unable to read user data: %w", err) - } - - users = append(users, user) - } - } - c.UserList = users - - c.renderUserList() - - return res, err -} - -func (c *Client) renderUserList() { - c.UI.userList.Clear() - for _, u := range c.UserList { - flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags))) - if flagBitmap.Bit(UserFlagAdmin) == 1 { - _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name) - } else { - _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name) - } - // TODO: fade if user is away - } -} - -func handleClientChatMsg(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) { - if c.Pref.EnableBell { - fmt.Println("\a") - } - - _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(FieldData).Data) - - return res, err -} - -func handleClientTranUserAccess(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) { - c.UserAccess = t.GetField(FieldUserAccess).Data - - return res, err -} - -func handleClientTranShowAgreement(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) { - agreement := string(t.GetField(FieldData).Data) - agreement = strings.ReplaceAll(agreement, "\r", "\n") - - agreeModal := tview.NewModal(). - SetText(agreement). - AddButtons([]string{"Agree", "Disagree"}). - SetDoneFunc(func(buttonIndex int, buttonLabel string) { - if buttonIndex == 0 { - res = append(res, - *NewTransaction( - TranAgreed, nil, - NewField(FieldUserName, []byte(c.Pref.Username)), - NewField(FieldUserIconID, c.Pref.IconBytes()), - NewField(FieldUserFlags, []byte{0x00, 0x00}), - NewField(FieldOptions, []byte{0x00, 0x00}), - ), - ) - c.UI.Pages.HidePage("agreement") - c.UI.App.SetFocus(c.UI.chatInput) - } else { - _ = c.Disconnect() - c.UI.Pages.SwitchToPage("home") - } - }, - ) - - c.UI.Pages.AddPage("agreement", agreeModal, false, true) - - return res, err -} - -func handleClientTranLogin(ctx context.Context, c *Client, t *Transaction) (res []Transaction, err error) { - if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) { - errMsg := string(t.GetField(FieldError).Data) - errModal := tview.NewModal() - errModal.SetText(errMsg) - errModal.AddButtons([]string{"Oh no"}) - errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) { - c.UI.Pages.RemovePage("errModal") - }) - c.UI.Pages.RemovePage("joinServer") - c.UI.Pages.AddPage("errModal", errModal, false, true) - - c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf? - - c.Logger.Error(string(t.GetField(FieldError).Data)) - return nil, errors.New("login error: " + string(t.GetField(FieldError).Data)) - } - c.UI.Pages.AddAndSwitchToPage(serverUIPage, c.UI.renderServerUI(), true) - c.UI.App.SetFocus(c.UI.chatInput) - - if err := c.Send(*NewTransaction(TranGetUserNameList, nil)); err != nil { - c.Logger.Error("err", "err", err) - } - return res, err -} - // JoinServer connects to a Hotline server and completes the login flow func (c *Client) Connect(address, login, passwd string) (err error) { // Establish TCP connection to server @@ -521,8 +77,18 @@ func (c *Client) Connect(address, login, passwd string) (err error) { } // Authenticate (send TranLogin 107) - if err := c.LogIn(login, passwd); err != nil { - return err + + err = c.Send( + *NewTransaction( + TranLogin, nil, + NewField(FieldUserName, []byte(c.Pref.Username)), + NewField(FieldUserIconID, c.Pref.IconBytes()), + NewField(FieldUserLogin, encodeString([]byte(login))), + NewField(FieldUserPassword, encodeString([]byte(passwd))), + ), + ) + if err != nil { + return fmt.Errorf("error sending login transaction: %w", err) } // start keepalive go routine @@ -575,18 +141,6 @@ func (c *Client) Handshake() error { return fmt.Errorf("handshake response err: %s", err) } -func (c *Client) LogIn(login string, password string) error { - return c.Send( - *NewTransaction( - TranLogin, nil, - NewField(FieldUserName, []byte(c.Pref.Username)), - NewField(FieldUserIconID, c.Pref.IconBytes()), - NewField(FieldUserLogin, encodeString([]byte(login))), - NewField(FieldUserPassword, encodeString([]byte(password))), - ), - ) -} - func (c *Client) Send(t Transaction) error { requestNum := binary.BigEndian.Uint16(t.Type) @@ -595,15 +149,11 @@ func (c *Client) Send(t Transaction) error { c.activeTasks[binary.BigEndian.Uint32(t.ID)] = &t } - b, err := t.MarshalBinary() + n, err := io.Copy(c.Connection, &t) if err != nil { - return err + return fmt.Errorf("error sending transaction: %w", err) } - var n int - if n, err = c.Connection.Write(b); err != nil { - return err - } c.Logger.Debug("Sent Transaction", "IsReply", t.IsReply, "type", requestNum, diff --git a/hotline/client_conn.go b/hotline/client_conn.go index f73265d..8e66218 100644 --- a/hotline/client_conn.go +++ b/hotline/client_conn.go @@ -60,7 +60,7 @@ func (cc *ClientConn) handleTransaction(transaction Transaction) error { field := transaction.GetField(reqField.ID) // Validate that required field is present - if field.ID == nil { + if field.ID == [2]byte{0, 0} { cc.logger.Error( "Missing required field", "RequestType", handler.Name, "FieldID", reqField.ID, @@ -166,7 +166,7 @@ func (cc *ClientConn) notifyOthers(t Transaction) (trans []Transaction) { // NewReply returns a reply Transaction with fields for the ClientConn func (cc *ClientConn) NewReply(t *Transaction, fields ...Field) Transaction { - reply := Transaction{ + return Transaction{ Flags: 0x00, IsReply: 0x01, Type: []byte{0x00, 0x00}, @@ -175,8 +175,6 @@ func (cc *ClientConn) NewReply(t *Transaction, fields ...Field) Transaction { ErrorCode: []byte{0, 0, 0, 0}, Fields: fields, } - - return reply } // NewErrReply returns an error reply Transaction with errMsg diff --git a/hotline/field.go b/hotline/field.go index c205c42..045e09f 100644 --- a/hotline/field.go +++ b/hotline/field.go @@ -2,6 +2,7 @@ package hotline import ( "encoding/binary" + "io" "slices" ) @@ -66,9 +67,11 @@ const ( ) type Field struct { - ID []byte // Type of field - FieldSize []byte // Size of the data part - Data []byte // Actual field content + ID [2]byte // Type of field + FieldSize [2]byte // Size of the data part + Data []byte // Actual field content + + readOffset int // Internal offset to track read progress } type requiredField struct { @@ -84,19 +87,55 @@ func NewField(id uint16, data []byte) Field { binary.BigEndian.PutUint16(bs, uint16(len(data))) return Field{ - ID: idBytes, - FieldSize: bs, + ID: [2]byte(idBytes), + FieldSize: [2]byte(bs), Data: data, } } -func (f Field) Payload() []byte { - return slices.Concat(f.ID, f.FieldSize, f.Data) +// fieldScanner implements bufio.SplitFunc for parsing byte slices into complete tokens +func fieldScanner(data []byte, _ bool) (advance int, token []byte, err error) { + if len(data) < minFieldLen { + return 0, nil, nil + } + + // tranLen represents the length of bytes that are part of the transaction + neededSize := minFieldLen + int(binary.BigEndian.Uint16(data[2:4])) + if neededSize > len(data) { + return 0, nil, nil + } + + return neededSize, data[0:neededSize], nil +} + +// Read implements io.Reader for Field +func (f *Field) Read(p []byte) (int, error) { + buf := slices.Concat(f.ID[:], f.FieldSize[:], f.Data) + + if f.readOffset >= len(buf) { + return 0, io.EOF // All bytes have been read + } + + n := copy(p, buf[f.readOffset:]) + f.readOffset += n + + return n, nil +} + +// Write implements io.Writer for Field +func (f *Field) Write(p []byte) (int, error) { + f.ID = [2]byte(p[0:2]) + f.FieldSize = [2]byte(p[2:4]) + + i := int(binary.BigEndian.Uint16(f.FieldSize[:])) + f.Data = p[4 : 4+i] + + return minFieldLen + i, nil } func getField(id int, fields *[]Field) *Field { for _, field := range *fields { - if id == int(binary.BigEndian.Uint16(field.ID)) { + if id == int(binary.BigEndian.Uint16(field.ID[:])) { return &field } } diff --git a/hotline/field_test.go b/hotline/field_test.go index fb686dc..317c2d9 100644 --- a/hotline/field_test.go +++ b/hotline/field_test.go @@ -1,7 +1,101 @@ package hotline -import "testing" +import ( + "fmt" + "github.com/stretchr/testify/assert" + "testing" +) func TestHello(t *testing.T) { } + +func Test_fieldScanner(t *testing.T) { + type args struct { + data []byte + in1 bool + } + tests := []struct { + name string + args args + wantAdvance int + wantToken []byte + wantErr assert.ErrorAssertionFunc + }{ + { + name: "when too few bytes are provided to read the field size", + args: args{ + data: []byte{}, + in1: false, + }, + wantAdvance: 0, + wantToken: []byte(nil), + wantErr: assert.NoError, + }, + { + name: "when too few bytes are provided to read the full payload", + args: args{ + data: []byte{ + 0, 1, + 0, 4, + 0, 0, + }, + in1: false, + }, + wantAdvance: 0, + wantToken: []byte(nil), + wantErr: assert.NoError, + }, + { + name: "when a full field is provided", + args: args{ + data: []byte{ + 0, 1, + 0, 4, + 0, 0, + 0, 0, + }, + in1: false, + }, + wantAdvance: 8, + wantToken: []byte{ + 0, 1, + 0, 4, + 0, 0, + 0, 0, + }, + wantErr: assert.NoError, + }, + { + name: "when a full field plus extra bytes are provided", + args: args{ + data: []byte{ + 0, 1, + 0, 4, + 0, 0, + 0, 0, + 1, 1, + }, + in1: false, + }, + wantAdvance: 8, + wantToken: []byte{ + 0, 1, + 0, 4, + 0, 0, + 0, 0, + }, + wantErr: assert.NoError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotAdvance, gotToken, err := fieldScanner(tt.args.data, tt.args.in1) + if !tt.wantErr(t, err, fmt.Sprintf("fieldScanner(%v, %v)", tt.args.data, tt.args.in1)) { + return + } + assert.Equalf(t, tt.wantAdvance, gotAdvance, "fieldScanner(%v, %v)", tt.args.data, tt.args.in1) + assert.Equalf(t, tt.wantToken, gotToken, "fieldScanner(%v, %v)", tt.args.data, tt.args.in1) + }) + } +} diff --git a/hotline/file_name_with_info.go b/hotline/file_name_with_info.go index ac64c74..4d59dd0 100644 --- a/hotline/file_name_with_info.go +++ b/hotline/file_name_with_info.go @@ -9,7 +9,7 @@ import ( type FileNameWithInfo struct { fileNameWithInfoHeader - name []byte // File name + Name []byte // File Name } // fileNameWithInfoHeader contains the fixed length fields of FileNameWithInfo @@ -19,7 +19,7 @@ type fileNameWithInfoHeader struct { FileSize [4]byte // File Size in bytes RSVD [4]byte NameScript [2]byte // ?? - NameSize [2]byte // Length of name field + NameSize [2]byte // Length of Name field } func (f *fileNameWithInfoHeader) nameLen() int { @@ -36,7 +36,7 @@ func (f *FileNameWithInfo) Read(b []byte) (int, error) { f.RSVD[:], f.NameScript[:], f.NameSize[:], - f.name, + f.Name, ), ), io.EOF } @@ -47,7 +47,7 @@ func (f *FileNameWithInfo) Write(p []byte) (int, error) { return 0, err } headerLen := binary.Size(f.fileNameWithInfoHeader) - f.name = p[headerLen : headerLen+f.nameLen()] + 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 0473631..321e247 100644 --- a/hotline/file_name_with_info_test.go +++ b/hotline/file_name_with_info_test.go @@ -47,7 +47,7 @@ func TestFileNameWithInfo_MarshalBinary(t *testing.T) { t.Run(tt.name, func(t *testing.T) { f := &FileNameWithInfo{ fileNameWithInfoHeader: tt.fields.fileNameWithInfoHeader, - name: tt.fields.name, + Name: tt.fields.name, } gotData, err := io.ReadAll(f) if (err != nil) != tt.wantErr { @@ -98,7 +98,7 @@ func TestFileNameWithInfo_UnmarshalBinary(t *testing.T) { NameScript: [2]byte{0, 0}, NameSize: [2]byte{0x00, 0x0e}, }, - name: []byte("Audion.app.zip"), + Name: []byte("Audion.app.zip"), }, wantErr: false, }, @@ -107,7 +107,7 @@ func TestFileNameWithInfo_UnmarshalBinary(t *testing.T) { t.Run(tt.name, func(t *testing.T) { f := &FileNameWithInfo{ fileNameWithInfoHeader: tt.fields.fileNameWithInfoHeader, - name: tt.fields.name, + Name: tt.fields.name, } if _, err := f.Write(tt.args.data); (err != nil) != tt.wantErr { t.Errorf("Write() error = %v, wantErr %v", err, tt.wantErr) diff --git a/hotline/file_wrapper.go b/hotline/file_wrapper.go index ec025dc..a26c45a 100644 --- a/hotline/file_wrapper.go +++ b/hotline/file_wrapper.go @@ -207,7 +207,7 @@ func (f *fileWrapper) delete() error { func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) { dataSize := make([]byte, 4) - mTime := make([]byte, 8) + mTime := [8]byte{} ft := defaultFileType @@ -257,8 +257,8 @@ func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) { Flags: []byte{0, 0, 0, 0}, PlatformFlags: []byte{0, 0, 1, 0}, // TODO: What is this? RSVD: make([]byte, 32), - CreateDate: mTime, // some filesystems don't support createTime - ModifyDate: mTime, + CreateDate: mTime[:], // some filesystems don't support createTime + ModifyDate: mTime[:], NameScript: []byte{0, 0}, Name: []byte(f.name), NameSize: []byte{0, 0}, diff --git a/hotline/files.go b/hotline/files.go index d4bea53..0963fbf 100644 --- a/hotline/files.go +++ b/hotline/files.go @@ -131,7 +131,7 @@ func getFileNameList(path string, ignoreList []string) (fields []Field, err erro binary.BigEndian.PutUint16(nameSize, uint16(len(strippedName))) copy(fnwi.NameSize[:], nameSize) - fnwi.name = []byte(strippedName) + fnwi.Name = []byte(strippedName) b, err := io.ReadAll(&fnwi) if err != nil { diff --git a/hotline/flattened_file_object.go b/hotline/flattened_file_object.go index dfa86e0..4bf8b4d 100644 --- a/hotline/flattened_file_object.go +++ b/hotline/flattened_file_object.go @@ -152,7 +152,7 @@ func (ffif *FlatFileInformationFork) Read(p []byte) (int, error) { ), io.EOF } -// Write implements the io.Writeer interface for FlatFileInformationFork +// Write implements the io.Writer interface for FlatFileInformationFork func (ffif *FlatFileInformationFork) Write(p []byte) (int, error) { nameSize := p[70:72] bs := binary.BigEndian.Uint16(nameSize) diff --git a/hotline/news.go b/hotline/news.go index 8e13a29..e6b7567 100644 --- a/hotline/news.go +++ b/hotline/news.go @@ -43,8 +43,8 @@ func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData { newArt := NewsArtList{ ID: id, - TimeStamp: art.Date, - ParentID: art.ParentArt, + TimeStamp: art.Date[:], + ParentID: art.ParentArt[:], Flags: []byte{0, 0, 0, 0}, FlavorCount: []byte{0, 0}, Title: []byte(art.Title), @@ -57,7 +57,11 @@ func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData { sort.Sort(byID(newsArts)) for _, v := range newsArts { - newsArtsPayload = append(newsArtsPayload, v.Payload()...) + b, err := io.ReadAll(&v) + if err != nil { + // TODO + } + newsArtsPayload = append(newsArtsPayload, b...) } nald := NewsArtListData{ @@ -73,15 +77,15 @@ func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData { // NewsArtData represents single news article type NewsArtData struct { - Title string `yaml:"Title"` - Poster string `yaml:"Poster"` - Date []byte `yaml:"Date"` // size 8 - PrevArt []byte `yaml:"PrevArt"` // size 4 - NextArt []byte `yaml:"NextArt"` // size 4 - ParentArt []byte `yaml:"ParentArt"` // size 4 - FirstChildArt []byte `yaml:"FirstChildArtArt"` // size 4 - DataFlav []byte `yaml:"DataFlav"` // "text/plain" - Data string `yaml:"Data"` + Title string `yaml:"Title"` + Poster string `yaml:"Poster"` + Date [8]byte `yaml:"Date"` // size 8 + PrevArt [4]byte `yaml:"PrevArt"` // size 4 + NextArt [4]byte `yaml:"NextArt"` // size 4 + ParentArt [4]byte `yaml:"ParentArt"` // size 4 + FirstChildArt [4]byte `yaml:"FirstChildArtArt"` // size 4 + DataFlav []byte `yaml:"DataFlav"` // "text/plain" + Data string `yaml:"Data"` } func (art *NewsArtData) DataSize() []byte { @@ -133,6 +137,8 @@ type NewsArtList struct { FlavorList []NewsFlavorList // Flavor list… Optional (if flavor count > 0) ArticleSize []byte // Size 2 + + readOffset int // Internal offset to track read progress } type byID []NewsArtList @@ -147,21 +153,29 @@ func (s byID) Less(i, j int) bool { return binary.BigEndian.Uint32(s[i].ID) < binary.BigEndian.Uint32(s[j].ID) } -func (nal *NewsArtList) Payload() []byte { - out := append(nal.ID, nal.TimeStamp...) - out = append(out, nal.ParentID...) - out = append(out, nal.Flags...) - - out = append(out, []byte{0, 1}...) +func (nal *NewsArtList) Read(p []byte) (int, error) { + out := slices.Concat( + nal.ID, + nal.TimeStamp, + nal.ParentID, + nal.Flags, + []byte{0, 1}, + []byte{uint8(len(nal.Title))}, + nal.Title, + []byte{uint8(len(nal.Poster))}, + nal.Poster, + []byte{0x0a, 0x74, 0x65, 0x78, 0x74, 0x2f, 0x70, 0x6c, 0x61, 0x69, 0x6e}, + nal.ArticleSize, + ) + + if nal.readOffset >= len(out) { + return 0, io.EOF // All bytes have been read + } - out = append(out, []byte{uint8(len(nal.Title))}...) - out = append(out, nal.Title...) - out = append(out, []byte{uint8(len(nal.Poster))}...) - out = append(out, nal.Poster...) - out = append(out, []byte{0x0a, 0x74, 0x65, 0x78, 0x74, 0x2f, 0x70, 0x6c, 0x61, 0x69, 0x6e}...) // TODO: wat? - out = append(out, nal.ArticleSize...) + n := copy(p, out[nal.readOffset:]) + nal.readOffset += n - return out + return n, io.EOF } type NewsFlavorList struct { diff --git a/hotline/server.go b/hotline/server.go index f2a69ad..b4841a7 100644 --- a/hotline/server.go +++ b/hotline/server.go @@ -152,24 +152,19 @@ func (s *Server) ServeFileTransfers(ctx context.Context, ln net.Listener) error func (s *Server) sendTransaction(t Transaction) error { clientID, err := byteToInt(*t.clientID) if err != nil { - return err + return fmt.Errorf("invalid client ID: %v", err) } s.mux.Lock() - client := s.Clients[uint16(clientID)] + client, ok := s.Clients[uint16(clientID)] s.mux.Unlock() - if client == nil { + if !ok || client == nil { return fmt.Errorf("invalid client id %v", *t.clientID) } - b, err := t.MarshalBinary() - if err != nil { - return err - } - - _, err = client.Connection.Write(b) + _, err = io.Copy(client.Connection, &t) if err != nil { - return err + return fmt.Errorf("failed to send transaction to client %v: %v", clientID, err) } return nil @@ -620,12 +615,7 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser NewField(FieldChatOptions, []byte{0, 0}), ) - b, err := t.MarshalBinary() - if err != nil { - return err - } - - _, err = rwc.Write(b) + _, err := io.Copy(rwc, t) if err != nil { return err } @@ -642,12 +632,8 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser NewField(FieldData, []byte("You are temporarily banned on this server")), NewField(FieldChatOptions, []byte{0, 0}), ) - b, err := t.MarshalBinary() - if err != nil { - return err - } - _, err = rwc.Write(b) + _, err := io.Copy(rwc, t) if err != nil { return err } @@ -677,13 +663,11 @@ func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser // If authentication fails, send error reply and close connection if !c.Authenticate(login, encodedPassword) { t := c.NewErrReply(&clientLogin, "Incorrect login.") - b, err := t.MarshalBinary() + + _, err := io.Copy(rwc, &t) if err != nil { return err } - if _, err := rwc.Write(b); err != nil { - return err - } c.logger.Info("Login failed", "clientVersion", fmt.Sprintf("%x", c.Version)) @@ -734,7 +718,7 @@ 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 = c.logger.With("Name", string(c.UserName)) c.logger.Info("Login successful", "clientVersion", "Not sent (probably 1.2.3)") @@ -838,7 +822,7 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro rLogger := s.Logger.With( "remoteAddr", ctx.Value(contextKeyReq).(requestCtx).remoteAddr, "login", fileTransfer.ClientConn.Account.Login, - "name", string(fileTransfer.ClientConn.UserName), + "Name", string(fileTransfer.ClientConn.UserName), ) fullPath, err := readPath(s.Config.FileRoot, fileTransfer.FilePath, fileTransfer.FileName) diff --git a/hotline/server_blackbox_test.go b/hotline/server_blackbox_test.go index 27ac4a7..059fe5a 100644 --- a/hotline/server_blackbox_test.go +++ b/hotline/server_blackbox_test.go @@ -1,7 +1,6 @@ package hotline import ( - "bytes" "encoding/hex" "github.com/stretchr/testify/assert" "log/slog" @@ -40,7 +39,7 @@ func tranAssertEqual(t *testing.T, tran1, tran2 []Transaction) bool { trans.ID = []byte{0, 0, 0, 0} var fs []Field for _, field := range trans.Fields { - if bytes.Equal(field.ID, []byte{0x00, 0x6b}) { + if field.ID == [2]byte{0x00, 0x6b} { continue } fs = append(fs, field) @@ -53,7 +52,7 @@ func tranAssertEqual(t *testing.T, tran1, tran2 []Transaction) bool { trans.ID = []byte{0, 0, 0, 0} var fs []Field for _, field := range trans.Fields { - if bytes.Equal(field.ID, []byte{0x00, 0x6b}) { + if field.ID == [2]byte{0x00, 0x6b} { continue } fs = append(fs, field) diff --git a/hotline/time.go b/hotline/time.go index 3b87864..edc700d 100644 --- a/hotline/time.go +++ b/hotline/time.go @@ -2,12 +2,13 @@ package hotline import ( "encoding/binary" + "slices" "time" ) // toHotlineTime 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 []byte) { +func toHotlineTime(t time.Time) (b [8]byte) { yearBytes := make([]byte, 2) secondBytes := make([]byte, 4) @@ -17,9 +18,9 @@ func toHotlineTime(t time.Time) (b []byte) { binary.BigEndian.PutUint16(yearBytes, uint16(t.Year())) binary.BigEndian.PutUint32(secondBytes, uint32(t.Sub(startOfYear).Seconds())) - b = append(b, yearBytes...) - b = append(b, []byte{0, 0}...) - b = append(b, secondBytes...) - - return b + return [8]byte(slices.Concat( + yearBytes, + []byte{0, 0}, + secondBytes, + )) } diff --git a/hotline/tracker.go b/hotline/tracker.go index a56059f..6ee2a9f 100644 --- a/hotline/tracker.go +++ b/hotline/tracker.go @@ -16,7 +16,7 @@ type TrackerRegistration struct { Port [2]byte // Server's listening TCP port number UserCount int // Number of users connected to this particular server PassID [4]byte // Random number generated by the server - Name string // Server name + Name string // Server Name Description string // Description of the server } @@ -67,7 +67,7 @@ type TrackerHeader struct { Version [2]byte // Old protocol (1) or new (2) } -// Message type 2 1 Sending list of servers +// Message type 2 1 Sending list of servers // Message data size 2 Remaining size of this request // Number of servers 2 Number of servers in the server list // Number of servers 2 Same as previous field @@ -83,8 +83,8 @@ type ServerRecord struct { Port [2]byte NumUsers [2]byte // Number of users connected to this particular server Unused [2]byte - NameSize byte // Length of name string - Name []byte // Server name + NameSize byte // Length of Name string + Name []byte // Server Name DescriptionSize byte Description []byte } @@ -125,7 +125,7 @@ func GetListing(addr string) ([]ServerRecord, error) { for { scanner.Scan() var srv ServerRecord - _, err = srv.Read(scanner.Bytes()) + _, err = srv.Write(scanner.Bytes()) if err != nil { return nil, err } @@ -175,8 +175,8 @@ func serverScanner(data []byte, _ bool) (advance int, token []byte, err error) { return 12 + nameLen + descLen, data[0 : 12+nameLen+descLen], nil } -// Read implements io.Reader for ServerRecord -func (s *ServerRecord) Read(b []byte) (n int, err error) { +// Write implements io.Writer for ServerRecord +func (s *ServerRecord) Write(b []byte) (n int, err error) { copy(s.IPAddr[:], b[0:4]) copy(s.Port[:], b[4:6]) copy(s.NumUsers[:], b[6:8]) diff --git a/hotline/transaction.go b/hotline/transaction.go index d9bbc22..7883bfb 100644 --- a/hotline/transaction.go +++ b/hotline/transaction.go @@ -1,10 +1,12 @@ package hotline import ( + "bufio" "bytes" "encoding/binary" "errors" "fmt" + "io" "math/rand" "slices" ) @@ -71,8 +73,6 @@ const ( ) type Transaction struct { - clientID *[]byte - Flags byte // Reserved (should be 0) IsReply byte // Request (0) or reply (1) Type []byte // Requested operation (user defined) @@ -82,6 +82,9 @@ type Transaction struct { DataSize []byte // Size of data in this transaction part. This allows splitting large transactions into smaller parts. ParamCount []byte // Number of the parameters for this transaction Fields []Field + + clientID *[]byte // Internal identifier for target client + readOffset int // Internal offset to track read progress } func NewTransaction(t int, clientID *[]byte, fields ...Field) *Transaction { @@ -113,9 +116,19 @@ func (t *Transaction) Write(p []byte) (n int, err error) { if tranLen > len(p) { return n, errors.New("buflen too small for tranLen") } - fields, err := ReadFields(p[20:22], p[22:tranLen]) - if err != nil { - return n, err + + // Create a new scanner for parsing incoming bytes into transaction tokens + scanner := bufio.NewScanner(bytes.NewReader(p[22:tranLen])) + scanner.Split(fieldScanner) + + for i := 0; i < int(binary.BigEndian.Uint16(p[20:22])); i++ { + scanner.Scan() + + var field Field + if _, err := field.Write(scanner.Bytes()); err != nil { + return 0, fmt.Errorf("error reading field: %w", err) + } + t.Fields = append(t.Fields, field) } t.Flags = p[0] @@ -126,7 +139,6 @@ func (t *Transaction) Write(p []byte) (n int, err error) { t.TotalSize = p[12:16] t.DataSize = p[16:20] t.ParamCount = p[20:22] - t.Fields = fields return len(p), err } @@ -177,8 +189,8 @@ func ReadFields(paramCount []byte, buf []byte) ([]Field, error) { } fields = append(fields, Field{ - ID: fieldID, - FieldSize: fieldSize, + ID: [2]byte(fieldID), + FieldSize: [2]byte(fieldSize), Data: buf[4 : 4+fieldSizeInt], }) @@ -192,18 +204,23 @@ func ReadFields(paramCount []byte, buf []byte) ([]Field, error) { return fields, nil } -func (t *Transaction) MarshalBinary() (data []byte, err error) { +// Read implements the io.Reader interface for Transaction +func (t *Transaction) Read(p []byte) (int, error) { payloadSize := t.Size() fieldCount := make([]byte, 2) binary.BigEndian.PutUint16(fieldCount, uint16(len(t.Fields))) - var fieldPayload []byte + bbuf := new(bytes.Buffer) + for _, field := range t.Fields { - fieldPayload = append(fieldPayload, field.Payload()...) + _, err := bbuf.ReadFrom(&field) + if err != nil { + return 0, fmt.Errorf("error reading field: %w", err) + } } - return slices.Concat( + buf := slices.Concat( []byte{t.Flags, t.IsReply}, t.Type, t.ID, @@ -211,8 +228,17 @@ func (t *Transaction) MarshalBinary() (data []byte, err error) { payloadSize, payloadSize, // this is the dataSize field, but seeming the same as totalSize fieldCount, - fieldPayload, - ), err + bbuf.Bytes(), + ) + + if t.readOffset >= len(buf) { + return 0, io.EOF // All bytes have been read + } + + n := copy(p, buf[t.readOffset:]) + t.readOffset += n + + return n, nil } // Size returns the total size of the transaction payload @@ -231,7 +257,7 @@ func (t *Transaction) Size() []byte { func (t *Transaction) GetField(id int) Field { for _, field := range t.Fields { - if id == int(binary.BigEndian.Uint16(field.ID)) { + if id == int(binary.BigEndian.Uint16(field.ID[:])) { return field } } diff --git a/hotline/transaction_handlers.go b/hotline/transaction_handlers.go index 3accf2c..1a079f7 100644 --- a/hotline/transaction_handlers.go +++ b/hotline/transaction_handlers.go @@ -1,6 +1,7 @@ package hotline import ( + "bufio" "bytes" "encoding/binary" "errors" @@ -414,11 +415,11 @@ func HandleGetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction, err e return res, err } -// HandleSetFileInfo updates a file or folder name and/or comment from the Get Info window +// HandleSetFileInfo updates a file or folder Name and/or comment from the Get Info window // Fields used in the request: -// * 201 File name +// * 201 File Name // * 202 File path Optional -// * 211 File new name Optional +// * 211 File new Name Optional // * 210 File comment Optional // Fields used in the reply: None func HandleSetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction, err error) { @@ -516,7 +517,7 @@ func HandleSetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction, err e // HandleDeleteFile deletes a file or folder // Fields used in the request: -// * 201 File name +// * 201 File Name // * 202 File path // Fields used in the reply: none func HandleDeleteFile(cc *ClientConn, t *Transaction) (res []Transaction, err error) { @@ -636,10 +637,10 @@ func HandleNewFolder(cc *ClientConn, t *Transaction) (res []Transaction, err err return res, fmt.Errorf("invalid filepath encoding: %w", err) } - // TODO: check path and folder name lengths + // TODO: check path and folder Name lengths if _, err := cc.Server.FS.Stat(newFolderPath); !os.IsNotExist(err) { - msg := fmt.Sprintf("Cannot create folder \"%s\" because there is already a file or folder with that name.", folderName) + msg := fmt.Sprintf("Cannot create folder \"%s\" because there is already a file or folder with that Name.", folderName) return []Transaction{cc.NewErrReply(t, msg)}, nil } @@ -771,9 +772,21 @@ func HandleListUsers(cc *ClientConn, t *Transaction) (res []Transaction, err err // 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, err error) { for _, field := range t.Fields { - subFields, err := ReadFields(field.Data[0:2], field.Data[2:]) - if err != nil { - return res, err + + 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, fmt.Errorf("error reading field: %w", err) + } + subFields = append(subFields, field) } // If there's only one subfield, that indicates this is a delete operation for the login in FieldData @@ -958,7 +971,7 @@ func HandleUserBroadcast(cc *ClientConn, t *Transaction) (res []Transaction, err // 103 User ID // // Fields used in the reply: -// 102 User name +// 102 User Name // 101 Data User info text string func HandleGetClientInfoText(cc *ClientConn, t *Transaction) (res []Transaction, err error) { if !cc.Authorize(accessGetClientInfo) { @@ -997,7 +1010,7 @@ func HandleTranAgreed(cc *ClientConn, t *Transaction) (res []Transaction, err er cc.Icon = t.GetField(FieldUserIconID).Data - cc.logger = cc.logger.With("name", string(cc.UserName)) + cc.logger = cc.logger.With("Name", string(cc.UserName)) cc.logger.Info("Login successful", "clientVersion", fmt.Sprintf("%v", func() int { i, _ := byteToInt(cc.Version); return i }())) options := t.GetField(FieldOptions).Data @@ -1006,19 +1019,19 @@ func HandleTranAgreed(cc *ClientConn, t *Transaction) (res []Transaction, err er flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(cc.Flags))) // Check refuse private PM option - if optBitmap.Bit(refusePM) == 1 { + if optBitmap.Bit(UserOptRefusePM) == 1 { flagBitmap.SetBit(flagBitmap, UserFlagRefusePM, 1) binary.BigEndian.PutUint16(cc.Flags, uint16(flagBitmap.Int64())) } // Check refuse private chat option - if optBitmap.Bit(refuseChat) == 1 { + if optBitmap.Bit(UserOptRefuseChat) == 1 { flagBitmap.SetBit(flagBitmap, UserFlagRefusePChat, 1) binary.BigEndian.PutUint16(cc.Flags, uint16(flagBitmap.Int64())) } // Check auto response - if optBitmap.Bit(autoResponse) == 1 { + if optBitmap.Bit(UserOptAutoResponse) == 1 { cc.AutoReply = t.GetField(FieldAutomaticResponse).Data } else { cc.AutoReply = []byte{} @@ -1207,7 +1220,7 @@ func HandleNewNewsCat(cc *ClientConn, t *Transaction) (res []Transaction, err er } // Fields used in the request: -// 322 News category name +// 322 News category Name // 325 News path func HandleNewNewsFldr(cc *ClientConn, t *Transaction) (res []Transaction, err error) { if !cc.Authorize(accessNewsCreateFldr) { @@ -1313,11 +1326,11 @@ func HandleGetNewsArtData(cc *ClientConn, t *Transaction) (res []Transaction, er 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(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)), )) @@ -1423,10 +1436,10 @@ func HandlePostNewsArt(cc *ClientConn, t *Transaction) (res []Transaction, err e Title: string(t.GetField(FieldNewsArtTitle).Data), Poster: string(cc.UserName), Date: toHotlineTime(time.Now()), - PrevArt: []byte{0, 0, 0, 0}, - NextArt: []byte{0, 0, 0, 0}, - ParentArt: bs, - FirstChildArt: []byte{0, 0, 0, 0}, + PrevArt: [4]byte{}, + NextArt: [4]byte{}, + ParentArt: [4]byte(bs), + FirstChildArt: [4]byte{}, DataFlav: []byte("text/plain"), Data: string(t.GetField(FieldNewsArtData).Data), } @@ -1442,10 +1455,10 @@ func HandlePostNewsArt(cc *ClientConn, t *Transaction) (res []Transaction, err e prevID := uint32(keys[len(keys)-1]) nextID = prevID + 1 - binary.BigEndian.PutUint32(newArt.PrevArt, prevID) + binary.BigEndian.PutUint32(newArt.PrevArt[:], prevID) // Set next article ID - binary.BigEndian.PutUint32(cat.Articles[prevID].NextArt, nextID) + binary.BigEndian.PutUint32(cat.Articles[prevID].NextArt[:], nextID) } // Update parent article with first child reply @@ -1453,8 +1466,8 @@ func HandlePostNewsArt(cc *ClientConn, t *Transaction) (res []Transaction, err e if parentID != 0 { parentArt := cat.Articles[parentID] - if bytes.Equal(parentArt.FirstChildArt, []byte{0, 0, 0, 0}) { - binary.BigEndian.PutUint32(parentArt.FirstChildArt, nextID) + if parentArt.FirstChildArt == [4]byte{0, 0, 0, 0} { + binary.BigEndian.PutUint32(parentArt.FirstChildArt[:], nextID) } } @@ -1582,7 +1595,7 @@ func HandleDownloadFolder(cc *ClientConn, t *Transaction) (res []Transaction, er // 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 +// 201 File Name // 202 File path // 108 transfer size Total size of all items in the folder // 220 Folder item count @@ -1617,7 +1630,7 @@ func HandleUploadFolder(cc *ClientConn, t *Transaction) (res []Transaction, err // HandleUploadFile // Fields used in the request: -// 201 File name +// 201 File Name // 202 File path // 204 File transfer options "Optional // Used only to resume download, currently has value 2" @@ -1653,7 +1666,7 @@ func HandleUploadFile(cc *ClientConn, t *Transaction) (res []Transaction, err er } if _, err := cc.Server.FS.Stat(fullFilePath); err == nil { - res = append(res, cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload because there is already a file named \"%v\". Try choosing a different name.", string(fileName)))) + res = append(res, cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload because there is already a file named \"%v\". Try choosing a different Name.", string(fileName)))) return res, err } @@ -1702,14 +1715,14 @@ func HandleSetClientUserInfo(cc *ClientConn, t *Transaction) (res []Transaction, optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options))) flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(cc.Flags))) - flagBitmap.SetBit(flagBitmap, UserFlagRefusePM, optBitmap.Bit(refusePM)) + flagBitmap.SetBit(flagBitmap, UserFlagRefusePM, optBitmap.Bit(UserOptRefusePM)) binary.BigEndian.PutUint16(cc.Flags, uint16(flagBitmap.Int64())) - flagBitmap.SetBit(flagBitmap, UserFlagRefusePChat, optBitmap.Bit(refuseChat)) + flagBitmap.SetBit(flagBitmap, UserFlagRefusePChat, optBitmap.Bit(UserOptRefuseChat)) binary.BigEndian.PutUint16(cc.Flags, uint16(flagBitmap.Int64())) // Check auto response - if optBitmap.Bit(autoResponse) == 1 { + if optBitmap.Bit(UserOptAutoResponse) == 1 { cc.AutoReply = t.GetField(FieldAutomaticResponse).Data } else { cc.AutoReply = []byte{} @@ -1894,7 +1907,7 @@ func HandleRejectChatInvite(cc *ClientConn, t *Transaction) (res []Transaction, // 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 User Name with info (Optional) // * 300 (more user names with info) func HandleJoinChat(cc *ClientConn, t *Transaction) (res []Transaction, err error) { chatID := t.GetField(FieldChatID).Data @@ -1997,7 +2010,7 @@ func HandleSetChatSubject(cc *ClientConn, t *Transaction) (res []Transaction, er // HandleMakeAlias makes a file alias using the specified path. // Fields used in the request: -// 201 File name +// 201 File Name // 202 File path // 212 File new path Destination path // diff --git a/hotline/transaction_handlers_test.go b/hotline/transaction_handlers_test.go index 81ba6e0..2420649 100644 --- a/hotline/transaction_handlers_test.go +++ b/hotline/transaction_handlers_test.go @@ -2858,7 +2858,7 @@ func TestHandleGetFileNameList(t *testing.T) { NameScript: [2]byte{}, NameSize: [2]byte{0, 0x0b}, }, - name: []byte("testfile-1k"), + Name: []byte("testfile-1k"), } b, _ := io.ReadAll(&fnwi) return b @@ -3817,7 +3817,7 @@ func TestHandleNewNewsFldr(t *testing.T) { wantErr: assert.NoError, }, //{ - // name: "when there is an error writing the threaded news file", + // Name: "when there is an error writing the threaded news file", // args: args{ // cc: &ClientConn{ // Account: &Account{ diff --git a/hotline/transaction_test.go b/hotline/transaction_test.go index 65f428a..cdb6f1e 100644 --- a/hotline/transaction_test.go +++ b/hotline/transaction_test.go @@ -32,13 +32,13 @@ func TestReadFields(t *testing.T) { }, want: []Field{ { - ID: []byte{0x00, 0x65}, - FieldSize: []byte{0x00, 0x04}, + ID: [2]byte{0x00, 0x65}, + FieldSize: [2]byte{0x00, 0x04}, Data: []byte{0x01, 0x02, 0x03, 0x04}, }, { - ID: []byte{0x00, 0x66}, - FieldSize: []byte{0x00, 0x02}, + ID: [2]byte{0x00, 0x66}, + FieldSize: [2]byte{0x00, 0x02}, Data: []byte{0x00, 0x01}, }, }, @@ -299,25 +299,7 @@ func Test_transactionScanner(t *testing.T) { } } -func TestTransaction_Write(t1 *testing.T) { - sampleTransaction := &Transaction{ - Flags: byte(0), - IsReply: byte(0), - Type: []byte{0x000, 0x93}, - ID: []byte{0x000, 0x00, 0x00, 0x01}, - ErrorCode: []byte{0x000, 0x00, 0x00, 0x00}, - TotalSize: []byte{0x000, 0x00, 0x00, 0x08}, - DataSize: []byte{0x000, 0x00, 0x00, 0x08}, - ParamCount: []byte{0x00, 0x01}, - Fields: []Field{ - { - ID: []byte{0x00, 0x01}, - FieldSize: []byte{0x00, 0x02}, - Data: []byte{0xff, 0xff}, - }, - }, - } - +func TestTransaction_Read(t1 *testing.T) { type fields struct { clientID *[]byte Flags byte @@ -329,28 +311,77 @@ func TestTransaction_Write(t1 *testing.T) { DataSize []byte ParamCount []byte Fields []Field + readOffset int } type args struct { p []byte } tests := []struct { - name string - fields fields - args args - wantN int - wantErr assert.ErrorAssertionFunc + name string + fields fields + args args + want int + wantErr assert.ErrorAssertionFunc + wantBytes []byte }{ { - name: "when buf contains all bytes for a single transaction", - fields: fields{}, + name: "returns transaction bytes", + fields: fields{ + Flags: 0x00, + IsReply: 0x01, + Type: []byte{0, 0}, + ID: []byte{0x9a, 0xcb, 0x04, 0x42}, + ErrorCode: []byte{0, 0, 0, 0}, + Fields: []Field{ + NewField(FieldData, []byte("TEST")), + }, + }, args: args{ - p: func() []byte { - b, _ := sampleTransaction.MarshalBinary() - return b - }(), + p: make([]byte, 1024), }, - wantN: 28, - wantErr: assert.NoError, + want: 30, + wantErr: assert.NoError, + wantBytes: []byte{0x0, 0x1, 0x0, 0x0, 0x9a, 0xcb, 0x4, 0x42, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xa, 0x0, 0x0, 0x0, 0xa, 0x0, 0x1, 0x0, 0x65, 0x0, 0x4, 0x54, 0x45, 0x53, 0x54}, + }, + { + name: "returns transaction bytes from readOffset", + fields: fields{ + Flags: 0x00, + IsReply: 0x01, + Type: []byte{0, 0}, + ID: []byte{0x9a, 0xcb, 0x04, 0x42}, + ErrorCode: []byte{0, 0, 0, 0}, + Fields: []Field{ + NewField(FieldData, []byte("TEST")), + }, + readOffset: 20, + }, + args: args{ + p: make([]byte, 1024), + }, + want: 10, + wantErr: assert.NoError, + wantBytes: []byte{0x0, 0x1, 0x0, 0x65, 0x0, 0x4, 0x54, 0x45, 0x53, 0x54}, + }, + { + name: "returns io.EOF when all bytes read", + fields: fields{ + Flags: 0x00, + IsReply: 0x01, + Type: []byte{0, 0}, + ID: []byte{0x9a, 0xcb, 0x04, 0x42}, + ErrorCode: []byte{0, 0, 0, 0}, + Fields: []Field{ + NewField(FieldData, []byte("TEST")), + }, + readOffset: 30, + }, + args: args{ + p: make([]byte, 1024), + }, + want: 0, + wantErr: assert.Error, + wantBytes: []byte{}, }, } for _, tt := range tests { @@ -366,12 +397,14 @@ func TestTransaction_Write(t1 *testing.T) { DataSize: tt.fields.DataSize, ParamCount: tt.fields.ParamCount, Fields: tt.fields.Fields, + readOffset: tt.fields.readOffset, } - gotN, err := t.Write(tt.args.p) - if !tt.wantErr(t1, err, fmt.Sprintf("Write(%v)", tt.args.p)) { + got, err := t.Read(tt.args.p) + if !tt.wantErr(t1, err, fmt.Sprintf("Read(%v)", tt.args.p)) { return } - assert.Equalf(t1, tt.wantN, gotN, "Write(%v)", tt.args.p) + assert.Equalf(t1, tt.want, got, "Read(%v)", tt.args.p) + assert.Equalf(t1, tt.wantBytes, tt.args.p[:got], "Read(%v)", tt.args.p) }) } } diff --git a/hotline/transfer_test.go b/hotline/transfer_test.go index 2bcee11..481e273 100644 --- a/hotline/transfer_test.go +++ b/hotline/transfer_test.go @@ -8,7 +8,7 @@ import ( "testing" ) -func TestTransfer_Read(t *testing.T) { +func TestTransfer_Write(t *testing.T) { type fields struct { Protocol [4]byte ReferenceNumber [4]byte @@ -146,7 +146,7 @@ func Test_receiveFile(t *testing.T) { wantErr: assert.NoError, }, // { - // name: "transfers fileWrapper when there is a resource fork", + // Name: "transfers fileWrapper when there is a resource fork", // args: args{ // conn: func() io.Reader { // testFile := flattenedFileObject{ diff --git a/hotline/ui.go b/hotline/ui.go deleted file mode 100644 index d9372d1..0000000 --- a/hotline/ui.go +++ /dev/null @@ -1,517 +0,0 @@ -package hotline - -import ( - "context" - "fmt" - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - "gopkg.in/yaml.v3" - "os" - "strconv" - "strings" -) - -type UI struct { - chatBox *tview.TextView - chatInput *tview.InputField - App *tview.Application - Pages *tview.Pages - userList *tview.TextView - trackerList *tview.List - HLClient *Client -} - -// pages -const ( - pageServerUI = "serverUI" -) - -func NewUI(c *Client) *UI { - app := tview.NewApplication() - chatBox := tview.NewTextView(). - SetScrollable(true). - SetDynamicColors(true). - SetWordWrap(true). - SetChangedFunc(func() { - app.Draw() // TODO: docs say this is bad but it's the only way to show content during initial render?? - }) - chatBox.Box.SetBorder(true).SetTitle("| Chat |") - - chatInput := tview.NewInputField() - chatInput. - SetLabel("> "). - SetFieldBackgroundColor(tcell.ColorDimGray). - SetDoneFunc(func(key tcell.Key) { - // skip send if user hit enter with no other text - if len(chatInput.GetText()) == 0 { - return - } - - _ = c.Send( - *NewTransaction(TranChatSend, nil, - NewField(FieldData, []byte(chatInput.GetText())), - ), - ) - chatInput.SetText("") // clear the input field after chat send - }) - - chatInput.Box.SetBorder(true).SetTitle("Send") - - userList := tview. - NewTextView(). - SetDynamicColors(true). - SetChangedFunc(func() { - app.Draw() // TODO: docs say this is bad but it's the only way to show content during initial render?? - }) - userList.Box.SetBorder(true).SetTitle("Users") - - return &UI{ - App: app, - chatBox: chatBox, - Pages: tview.NewPages(), - chatInput: chatInput, - userList: userList, - trackerList: tview.NewList(), - HLClient: c, - } -} - -func (ui *UI) showBookmarks() *tview.List { - list := tview.NewList() - list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - ui.Pages.SwitchToPage("home") - } - return event - }) - list.Box.SetBorder(true).SetTitle("| Bookmarks |") - - shortcut := 97 // rune for "a" - for i, srv := range ui.HLClient.Pref.Bookmarks { - addr := srv.Addr - login := srv.Login - pass := srv.Password - list.AddItem(srv.Name, srv.Addr, rune(shortcut+i), func() { - ui.Pages.RemovePage("joinServer") - - newJS := ui.renderJoinServerForm("", addr, login, pass, "bookmarks", true, true) - - ui.Pages.AddPage("joinServer", newJS, true, true) - }) - } - - return list -} - -func (ui *UI) getTrackerList(servers []ServerRecord) *tview.List { - list := tview.NewList() - list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - ui.Pages.SwitchToPage("home") - } - return event - }) - list.Box.SetBorder(true).SetTitle("| Servers |") - - const shortcut = 97 // rune for "a" - for i, _ := range servers { - srv := servers[i] - list.AddItem(string(srv.Name), string(srv.Description), rune(shortcut+i), func() { - ui.Pages.RemovePage("joinServer") - - newJS := ui.renderJoinServerForm(string(srv.Name), srv.Addr(), GuestAccount, "", trackerListPage, false, true) - - ui.Pages.AddPage("joinServer", newJS, true, true) - ui.Pages.ShowPage("joinServer") - }) - } - - return list -} - -func (ui *UI) renderSettingsForm() *tview.Flex { - iconStr := strconv.Itoa(ui.HLClient.Pref.IconID) - settingsForm := tview.NewForm() - settingsForm.AddInputField("Your Name", ui.HLClient.Pref.Username, 0, nil, nil) - settingsForm.AddInputField("IconID", iconStr, 0, func(idStr string, _ rune) bool { - _, err := strconv.Atoi(idStr) - return err == nil - }, nil) - settingsForm.AddInputField("Tracker", ui.HLClient.Pref.Tracker, 0, nil, nil) - settingsForm.AddCheckbox("Enable Terminal Bell", ui.HLClient.Pref.EnableBell, nil) - settingsForm.AddButton("Save", func() { - usernameInput := settingsForm.GetFormItem(0).(*tview.InputField).GetText() - if len(usernameInput) == 0 { - usernameInput = "unnamed" - } - ui.HLClient.Pref.Username = usernameInput - iconStr = settingsForm.GetFormItem(1).(*tview.InputField).GetText() - ui.HLClient.Pref.IconID, _ = strconv.Atoi(iconStr) - ui.HLClient.Pref.Tracker = settingsForm.GetFormItem(2).(*tview.InputField).GetText() - ui.HLClient.Pref.EnableBell = settingsForm.GetFormItem(3).(*tview.Checkbox).IsChecked() - - out, err := yaml.Marshal(&ui.HLClient.Pref) - if err != nil { - // TODO: handle err - } - // TODO: handle err - err = os.WriteFile(ui.HLClient.cfgPath, out, 0666) - if err != nil { - println(ui.HLClient.cfgPath) - panic(err) - } - ui.Pages.RemovePage("settings") - }) - settingsForm.SetBorder(true) - settingsForm.SetCancelFunc(func() { - ui.Pages.RemovePage("settings") - }) - settingsPage := tview.NewFlex().SetDirection(tview.FlexRow) - settingsPage.Box.SetBorder(true).SetTitle("Settings") - settingsPage.AddItem(settingsForm, 0, 1, true) - - centerFlex := tview.NewFlex(). - AddItem(nil, 0, 1, false). - AddItem(tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(nil, 0, 1, false). - AddItem(settingsForm, 15, 1, true). - AddItem(nil, 0, 1, false), 40, 1, true). - AddItem(nil, 0, 1, false) - - return centerFlex -} - -func (ui *UI) joinServer(addr, login, password string) error { - // append default port to address if no port supplied - if len(strings.Split(addr, ":")) == 1 { - addr += ":5500" - } - if err := ui.HLClient.Connect(addr, login, password); err != nil { - return fmt.Errorf("Error joining server: %v\n", err) - } - - go func() { - if err := ui.HLClient.HandleTransactions(context.TODO()); err != nil { - ui.Pages.SwitchToPage("home") - } - - loginErrModal := tview.NewModal(). - AddButtons([]string{"Ok"}). - SetText("The server connection has closed."). - SetDoneFunc(func(buttonIndex int, buttonLabel string) { - ui.Pages.SwitchToPage("home") - }) - loginErrModal.Box.SetTitle("Server Connection Error") - - ui.Pages.AddPage("loginErr", loginErrModal, false, true) - ui.App.Draw() - }() - - return nil -} - -func (ui *UI) renderJoinServerForm(name, server, login, password, backPage string, save, defaultConnect bool) *tview.Flex { - joinServerForm := tview.NewForm() - joinServerForm. - AddInputField("Server", server, 0, nil, nil). - AddInputField("Login", login, 0, nil, nil). - AddPasswordField("Password", password, 0, '*', nil). - AddCheckbox("Save", save, func(checked bool) { - ui.HLClient.Pref.AddBookmark( - joinServerForm.GetFormItem(0).(*tview.InputField).GetText(), - joinServerForm.GetFormItem(0).(*tview.InputField).GetText(), - joinServerForm.GetFormItem(1).(*tview.InputField).GetText(), - joinServerForm.GetFormItem(2).(*tview.InputField).GetText(), - ) - - out, err := yaml.Marshal(ui.HLClient.Pref) - if err != nil { - panic(err) - } - - err = os.WriteFile(ui.HLClient.cfgPath, out, 0666) - if err != nil { - panic(err) - } - }). - AddButton("Cancel", func() { - ui.Pages.SwitchToPage(backPage) - }). - AddButton("Connect", func() { - srvAddr := joinServerForm.GetFormItem(0).(*tview.InputField).GetText() - loginInput := joinServerForm.GetFormItem(1).(*tview.InputField).GetText() - err := ui.joinServer( - srvAddr, - loginInput, - joinServerForm.GetFormItem(2).(*tview.InputField).GetText(), - ) - if name == "" { - name = fmt.Sprintf("%s@%s", loginInput, srvAddr) - } - ui.HLClient.serverName = name - - if err != nil { - ui.HLClient.Logger.Error("login error", "err", err) - loginErrModal := tview.NewModal(). - AddButtons([]string{"Oh no"}). - SetText(err.Error()). - SetDoneFunc(func(buttonIndex int, buttonLabel string) { - ui.Pages.SwitchToPage(backPage) - }) - - ui.Pages.AddPage("loginErr", loginErrModal, false, true) - } - - // Save checkbox - if joinServerForm.GetFormItem(3).(*tview.Checkbox).IsChecked() { - // TODO: implement bookmark saving - } - }) - - joinServerForm.Box.SetBorder(true).SetTitle("| Connect |") - joinServerForm.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - ui.Pages.SwitchToPage(backPage) - } - return event - }) - - if defaultConnect { - joinServerForm.SetFocus(5) - } - - joinServerPage := tview.NewFlex(). - AddItem(nil, 0, 1, false). - AddItem(tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(nil, 0, 1, false). - AddItem(joinServerForm, 14, 1, true). - AddItem(nil, 0, 1, false), 40, 1, true). - AddItem(nil, 0, 1, false) - - return joinServerPage -} - -func (ui *UI) renderServerUI() *tview.Flex { - ui.chatBox.SetText("") // clear any previously existing chatbox text - commandList := tview.NewTextView().SetDynamicColors(true) - commandList. - SetText("[yellow]^n[-::]: Read News [yellow]^p[-::]: Post News\n[yellow]^l[-::]: View Logs [yellow]^f[-::]: View Files\n"). - SetBorder(true). - SetTitle("| Keyboard Shortcuts| ") - - modal := tview.NewModal(). - SetText("Disconnect from the server?"). - AddButtons([]string{"Cancel", "Exit"}). - SetFocus(1) - modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) { - if buttonIndex == 1 { - _ = ui.HLClient.Disconnect() - ui.Pages.RemovePage(pageServerUI) - ui.Pages.SwitchToPage("home") - } else { - ui.Pages.HidePage("modal") - } - }) - - serverUI := tview.NewFlex(). - AddItem(tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(commandList, 4, 0, false). - AddItem(ui.chatBox, 0, 8, false). - AddItem(ui.chatInput, 3, 0, true), 0, 1, true). - AddItem(ui.userList, 25, 1, false) - serverUI.SetBorder(true).SetTitle("| Mobius - Connected to " + ui.HLClient.serverName + " |").SetTitleAlign(tview.AlignLeft) - serverUI.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - ui.Pages.AddPage("modal", modal, false, true) - } - - // List files - if event.Key() == tcell.KeyCtrlF { - if err := ui.HLClient.Send(*NewTransaction(TranGetFileNameList, nil)); err != nil { - ui.HLClient.Logger.Error("err", "err", err) - } - } - - // Show News - if event.Key() == tcell.KeyCtrlN { - if err := ui.HLClient.Send(*NewTransaction(TranGetMsgs, nil)); err != nil { - ui.HLClient.Logger.Error("err", "err", err) - } - } - - // Post news - if event.Key() == tcell.KeyCtrlP { - newsFlex := tview.NewFlex() - newsFlex.SetBorderPadding(0, 0, 1, 1) - newsPostTextArea := tview.NewTextView() - newsPostTextArea.SetBackgroundColor(tcell.ColorDarkSlateGrey) - newsPostTextArea.SetChangedFunc(func() { - ui.App.Draw() // TODO: docs say this is bad but it's the only way to show content during initial render?? - }) - - newsPostForm := tview.NewForm(). - SetButtonsAlign(tview.AlignRight). - // AddButton("Cancel", nil). // TODO: implement cancel button behavior - AddButton("Send", nil) - newsPostForm.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyEscape: - ui.Pages.RemovePage("newsInput") - case tcell.KeyTab: - ui.App.SetFocus(newsPostTextArea) - case tcell.KeyEnter: - newsText := strings.ReplaceAll(newsPostTextArea.GetText(true), "\n", "\r") - if len(newsText) == 0 { - return event - } - err := ui.HLClient.Send( - *NewTransaction(TranOldPostNews, nil, - NewField(FieldData, []byte(newsText)), - ), - ) - if err != nil { - ui.HLClient.Logger.Error("Error posting news", "err", err) - // TODO: display errModal to user - } - ui.Pages.RemovePage("newsInput") - } - - return event - }) - - newsFlex. - SetDirection(tview.FlexRow). - SetBorder(true). - SetTitle("| Post Message |") - - newsPostTextArea.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyEscape: - ui.Pages.RemovePage("newsInput") - case tcell.KeyTab: - ui.App.SetFocus(newsPostForm) - case tcell.KeyEnter: - _, _ = fmt.Fprintf(newsPostTextArea, "\n") - default: - const windowsBackspaceRune = 8 - const macBackspaceRune = 127 - switch event.Rune() { - case macBackspaceRune, windowsBackspaceRune: - curTxt := newsPostTextArea.GetText(true) - if len(curTxt) > 0 { - curTxt = curTxt[:len(curTxt)-1] - newsPostTextArea.SetText(curTxt) - } - default: - _, _ = fmt.Fprint(newsPostTextArea, string(event.Rune())) - } - } - - return event - }) - - newsFlex.AddItem(newsPostTextArea, 10, 0, true) - newsFlex.AddItem(newsPostForm, 3, 0, false) - - newsPostPage := tview.NewFlex(). - AddItem(nil, 0, 1, false). - AddItem(tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(nil, 0, 1, false). - AddItem(newsFlex, 15, 1, true). - // AddItem(newsPostForm, 3, 0, false). - AddItem(nil, 0, 1, false), 40, 1, false). - AddItem(nil, 0, 1, false) - - ui.Pages.AddPage("newsInput", newsPostPage, true, true) - ui.App.SetFocus(newsPostTextArea) - } - - return event - }) - return serverUI -} - -func (ui *UI) Start() { - home := tview.NewFlex().SetDirection(tview.FlexRow) - home.Box.SetBorder(true).SetTitle("| Mobius v" + VERSION + " |").SetTitleAlign(tview.AlignLeft) - mainMenu := tview.NewList() - - bannerItem := tview.NewTextView(). - SetText(randomBanner()). - SetDynamicColors(true). - SetTextAlign(tview.AlignCenter) - - home.AddItem( - tview.NewFlex().AddItem(bannerItem, 0, 1, false), - 14, 1, false) - home.AddItem(tview.NewFlex(). - AddItem(nil, 0, 1, false). - AddItem(mainMenu, 0, 1, true). - AddItem(nil, 0, 1, false), - 0, 1, true, - ) - - mainMenu.AddItem("Join Server", "", 'j', func() { - joinServerPage := ui.renderJoinServerForm("", "", GuestAccount, "", "home", false, false) - ui.Pages.AddPage("joinServer", joinServerPage, true, true) - }). - AddItem("Bookmarks", "", 'b', func() { - ui.Pages.AddAndSwitchToPage("bookmarks", ui.showBookmarks(), true) - }). - AddItem("Browse Tracker", "", 't', func() { - listing, err := GetListing(ui.HLClient.Pref.Tracker) - if err != nil { - errMsg := fmt.Sprintf("Error fetching tracker results:\n%v", err) - errModal := tview.NewModal() - errModal.SetText(errMsg) - errModal.AddButtons([]string{"Cancel"}) - errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) { - ui.Pages.RemovePage("errModal") - }) - ui.Pages.RemovePage("joinServer") - ui.Pages.AddPage("errModal", errModal, false, true) - return - } - ui.trackerList = ui.getTrackerList(listing) - ui.Pages.AddAndSwitchToPage("trackerList", ui.trackerList, true) - }). - AddItem("Settings", "", 's', func() { - ui.Pages.AddPage("settings", ui.renderSettingsForm(), true, true) - }). - AddItem("Quit", "", 'q', func() { - ui.App.Stop() - }) - - ui.Pages.AddPage("home", home, true, true) - - // App level input capture - ui.App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyCtrlC { - ui.HLClient.Logger.Info("Exiting") - ui.App.Stop() - os.Exit(0) - } - // Show Logs - if event.Key() == tcell.KeyCtrlL { - ui.HLClient.DebugBuf.TextView.ScrollToEnd() - ui.HLClient.DebugBuf.TextView.SetBorder(true).SetTitle("Logs") - ui.HLClient.DebugBuf.TextView.SetDoneFunc(func(key tcell.Key) { - if key == tcell.KeyEscape { - ui.Pages.RemovePage("logs") - } - }) - - ui.Pages.AddPage("logs", ui.HLClient.DebugBuf.TextView, true, true) - } - return event - }) - - if err := ui.App.SetRoot(ui.Pages, true).SetFocus(ui.Pages).Run(); err != nil { - ui.App.Stop() - os.Exit(1) - } -} diff --git a/hotline/user.go b/hotline/user.go index e64bf84..02deb31 100644 --- a/hotline/user.go +++ b/hotline/user.go @@ -8,17 +8,17 @@ import ( // User flags are stored as a 2 byte bitmap and represent various user states const ( - UserFlagAway = 0 // User is away - UserFlagAdmin = 1 // User is admin - UserFlagRefusePM = 2 // User refuses private messages - UserFlagRefusePChat = 3 // User refuses private chat + UserFlagAway = iota // User is away + UserFlagAdmin // User is admin + UserFlagRefusePM // User refuses private messages + UserFlagRefusePChat // User refuses private chat ) // FieldOptions flags are sent from v1.5+ clients as part of TranAgreed const ( - refusePM = 0 // User has "Refuse private messages" pref set - refuseChat = 1 // User has "Refuse private chat" pref set - autoResponse = 2 // User has "Automatic response" pref set + UserOptRefusePM = iota // User has "Refuse private messages" pref set + UserOptRefuseChat // User has "Refuse private chat" pref set + UserOptAutoResponse // User has "Automatic response" pref set ) type User struct { @@ -26,6 +26,8 @@ type User struct { Icon []byte // Size 2 Flags []byte // Size 2 Name string // Variable length user name + + readOffset int // Internal offset to track read progress } func (u *User) Read(p []byte) (int, error) { @@ -40,18 +42,21 @@ func (u *User) Read(p []byte) (int, error) { u.Flags = u.Flags[2:] } - out := append(u.ID[:2], u.Icon[:2]...) - out = append(out, u.Flags[:2]...) - out = append(out, nameLen...) - out = append(out, u.Name...) - - return copy(p, slices.Concat( + b := slices.Concat( u.ID, u.Icon, u.Flags, nameLen, []byte(u.Name), - )), io.EOF + ) + + if u.readOffset >= len(b) { + return 0, io.EOF // All bytes have been read + } + + n := copy(p, b) + + return n, io.EOF } func (u *User) Write(p []byte) (int, error) {