+++ /dev/null
-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
-}
+++ /dev/null
-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
"error": slog.LevelError,
}
+var (
+ version = "dev"
+ commit = "none"
+ date = "unknown"
+)
+
func main() {
ctx, cancel := context.WithCancel(context.Background())
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")
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)
}
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
)
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
)
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=
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=
import (
"encoding/binary"
+ "fmt"
"golang.org/x/crypto/bcrypt"
"io"
"log"
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))),
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
+++ /dev/null
- __ __ ______ ______ __ __ __ __ ______
-/\ \_\ \ /\ __ \ /\__ _\ /\ \ /\ \ /\ "-.\ \ /\ ___\
-\ \ __ \ \ \ \/\ \ \/_/\ \/ \ \ \____ \ \ \ \ \ \-. \ \ \ __\
- \ \_\ \_\ \ \_____\ \ \_\ \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\
- \/_/\/_/ \/_____/ \/_/ \/_____/ \/_/ \/_/ \/_/ \/_____/
-
\ No newline at end of file
+++ /dev/null
- ▄█ █▄ ▄██████▄ ███ ▄█ ▄█ ███▄▄▄▄ ▄████████
- ███ ███ ███ ███ ▀█████████▄ ███ ███ ███▀▀▀██▄ ███ ███
- ███ ███ ███ ███ ▀███▀▀██ ███ ███▌ ███ ███ ███ █▀
- ▄███▄▄▄▄███▄▄ ███ ███ ███ ▀ ███ ███▌ ███ ███ ▄███▄▄▄
-▀▀███▀▀▀▀███▀ ███ ███ ███ ███ ███▌ ███ ███ ▀▀███▀▀▀
- ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ █▄
- ███ ███ ███ ███ ███ ███▌ ▄ ███ ███ ███ ███ ███
- ███ █▀ ▀██████▀ ▄████▀ █████▄▄██ █▀ ▀█ █▀ ██████████
- ▀
\ No newline at end of file
+++ /dev/null
- __ __ ______ ______ __ __ __ __ ______
-/\ \_\ \ /\ __ \ /\__ _\ /\ \ /\ \ /\ "-.\ \ /\ ___\
-\ \ __ \ \ \ \/\ \ \/_/\ \/ \ \ \____ \ \ \ \ \ \-. \ \ \ __\
- \ \_\ \_\ \ \_____\ \ \_\ \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\
- \/_/\/_/ \/_____/ \/_/ \/_____/ \/_/ \/_/ \/_/ \/_____/
-
\ No newline at end of file
+++ /dev/null
-.___.__ ._______ _____._.___ .___ .______ ._______
-: | \ : .___ \ \__ _:|| | : __|: \ : .____/
-| : || : | | | :|| | | : || || : _/\
-| . || : | | || |/\ | || | || / \
-|___| | \_. ___/ | || / \| ||___| ||_.: __/
- |___| :/ |___||______/|___| |___| :/
- :
+++ /dev/null
- ▄▀▀▄ ▄▄ ▄▀▀▀▀▄ ▄▀▀▀█▀▀▄ ▄▀▀▀▀▄ ▄▀▀█▀▄ ▄▀▀▄ ▀▄ ▄▀▀█▄▄▄▄
-█ █ ▄▀ █ █ █ █ ▐ █ █ █ █ █ █ █ █ █ ▐ ▄▀ ▐
-▐ █▄▄▄█ █ █ ▐ █ ▐ █ ▐ █ ▐ ▐ █ ▀█ █▄▄▄▄▄
- █ █ ▀▄ ▄▀ █ █ █ █ █ █ ▌
- ▄▀ ▄▀ ▀▀▀▀ ▄▀ ▄▀▄▄▄▄▄▄▀ ▄▀▀▀▀▀▄ ▄▀ █ ▄▀▄▄▄▄
- █ █ █ █ █ █ █ ▐ █ ▐
- ▐ ▐ ▐ ▐ ▐ ▐ ▐ ▐
\ No newline at end of file
+++ /dev/null
- :: .: ... :::::::::::: ::: ::::::. :::..,::::::
- ,;; ;;, .;;;;;;;. ;;;;;;;;'''' ;;; ;;;`;;;;, `;;;;;;;''''
-,[[[,,,[[[ ,[[ \[[, [[ [[[ [[[ [[[[[. '[[ [[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
+++ /dev/null
-██╗ ██╗ ██████╗ ████████╗██╗ ██╗███╗ ██╗███████╗
-██║ ██║██╔═══██╗╚══██╔══╝██║ ██║████╗ ██║██╔════╝
-███████║██║ ██║ ██║ ██║ ██║██╔██╗ ██║█████╗
-██╔══██║██║ ██║ ██║ ██║ ██║██║╚██╗██║██╔══╝
-██║ ██║╚██████╔╝ ██║ ███████╗██║██║ ╚████║███████╗
-╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝
-
+++ /dev/null
- ██░ ██ ▒█████ ▄▄▄█████▓ ██▓ ██▓ ███▄ █ ▓█████
-▓██░ ██▒▒██▒ ██▒▓ ██▒ ▓▒▓██▒ ▓██▒ ██ ▀█ █ ▓█ ▀
-▒██▀▀██░▒██░ ██▒▒ ▓██░ ▒░▒██░ ▒██▒▓██ ▀█ ██▒▒███
-░▓█ ░██ ▒██ ██░░ ▓██▓ ░ ▒██░ ░██░▓██▒ ▐▌██▒▒▓█ ▄
-░▓█▒░██▓░ ████▓▒░ ▒██▒ ░ ░██████▒░██░▒██░ ▓██░░▒████▒
- ▒ ░░▒░▒░ ▒░▒░▒░ ▒ ░░ ░ ▒░▓ ░░▓ ░ ▒░ ▒ ▒ ░░ ▒░ ░
- ▒ ░▒░ ░ ░ ▒ ▒░ ░ ░ ░ ▒ ░ ▒ ░░ ░░ ░ ▒░ ░ ░ ░
- ░ ░░ ░░ ░ ░ ▒ ░ ░ ░ ▒ ░ ░ ░ ░ ░
- ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
-
+++ /dev/null
- █████ █████ █████ ████ ███
-░░███ ░░███ ░░███ ░░███ ░░░
- ░███ ░███ ██████ ███████ ░███ ████ ████████ ██████
- ░███████████ ███░░███░░░███░ ░███ ░░███ ░░███░░███ ███░░███
- ░███░░░░░███ ░███ ░███ ░███ ░███ ░███ ░███ ░███ ░███████
- ░███ ░███ ░███ ░███ ░███ ███ ░███ ░███ ░███ ░███ ░███░░░
- █████ █████░░██████ ░░█████ █████ █████ ████ █████░░██████
-░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░ ░░░░░ ░░░░░░
-
"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 {
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)
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)
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
}
// 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
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)
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,
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,
// 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},
ErrorCode: []byte{0, 0, 0, 0},
Fields: fields,
}
-
- return reply
}
// NewErrReply returns an error reply Transaction with errMsg
import (
"encoding/binary"
+ "io"
"slices"
)
)
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 {
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
}
}
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)
+ })
+ }
+}
type FileNameWithInfo struct {
fileNameWithInfoHeader
- name []byte // File name
+ Name []byte // File Name
}
// fileNameWithInfoHeader contains the fixed length fields of FileNameWithInfo
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 {
f.RSVD[:],
f.NameScript[:],
f.NameSize[:],
- f.name,
+ f.Name,
),
), io.EOF
}
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
}
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 {
NameScript: [2]byte{0, 0},
NameSize: [2]byte{0x00, 0x0e},
},
- name: []byte("Audion.app.zip"),
+ Name: []byte("Audion.app.zip"),
},
wantErr: false,
},
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)
func (f *fileWrapper) flattenedFileObject() (*flattenedFileObject, error) {
dataSize := make([]byte, 4)
- mTime := make([]byte, 8)
+ mTime := [8]byte{}
ft := defaultFileType
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},
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 {
), 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)
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),
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{
// 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 {
FlavorList []NewsFlavorList
// Flavor list… Optional (if flavor count > 0)
ArticleSize []byte // Size 2
+
+ readOffset int // Internal offset to track read progress
}
type byID []NewsArtList
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 {
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
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
}
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
}
// 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))
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)")
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)
package hotline
import (
- "bytes"
"encoding/hex"
"github.com/stretchr/testify/assert"
"log/slog"
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)
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)
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)
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,
+ ))
}
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
}
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
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
}
for {
scanner.Scan()
var srv ServerRecord
- _, err = srv.Read(scanner.Bytes())
+ _, err = srv.Write(scanner.Bytes())
if err != nil {
return nil, err
}
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])
package hotline
import (
+ "bufio"
"bytes"
"encoding/binary"
"errors"
"fmt"
+ "io"
"math/rand"
"slices"
)
)
type Transaction struct {
- clientID *[]byte
-
Flags byte // Reserved (should be 0)
IsReply byte // Request (0) or reply (1)
Type []byte // Requested operation (user defined)
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 {
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]
t.TotalSize = p[12:16]
t.DataSize = p[16:20]
t.ParamCount = p[20:22]
- t.Fields = fields
return len(p), err
}
}
fields = append(fields, Field{
- ID: fieldID,
- FieldSize: fieldSize,
+ ID: [2]byte(fieldID),
+ FieldSize: [2]byte(fieldSize),
Data: buf[4 : 4+fieldSizeInt],
})
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,
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
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
}
}
package hotline
import (
+ "bufio"
"bytes"
"encoding/binary"
"errors"
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) {
// 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) {
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
}
// 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
// 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) {
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
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{}
}
// 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) {
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)),
))
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),
}
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
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)
}
}
// 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
// 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"
}
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
}
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{}
// 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
// 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
//
NameScript: [2]byte{},
NameSize: [2]byte{0, 0x0b},
},
- name: []byte("testfile-1k"),
+ Name: []byte("testfile-1k"),
}
b, _ := io.ReadAll(&fnwi)
return b
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{
},
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},
},
},
}
}
-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
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 {
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)
})
}
}
"testing"
)
-func TestTransfer_Read(t *testing.T) {
+func TestTransfer_Write(t *testing.T) {
type fields struct {
Protocol [4]byte
ReferenceNumber [4]byte
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{
+++ /dev/null
-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)
- }
-}
// 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 {
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) {
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) {