* Added ability to reload config, agreement, news, and user accounts without restarting the server by sending SIGHUP to the running process
* Added ability to use modern unix or windows line breaks in Agreement.txt and MessageBoard.txt instead of classic MacOS `\r` breaks.
* Extensive refactor towards swappable backends for the active server state
* Extensive refactored towards making the hotline package generic and re-usable for alternate server implemenations
* Fix bug where users whose accounts have been deleted would not be disconnected
"os"
"os/signal"
"path"
- "runtime"
+ "path/filepath"
"syscall"
)
//go:embed mobius/config
var cfgTemplate embed.FS
-const defaultPort = 5500
-
var logLevels = map[string]slog.Level{
"debug": slog.LevelDebug,
"info": slog.LevelInfo,
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGINT, os.Interrupt)
netInterface := flag.String("interface", "", "IP addr of interface to listen on. Defaults to all interfaces.")
- basePort := flag.Int("bind", defaultPort, "Base Hotline server port. File transfer port is base port + 1.")
+ basePort := flag.Int("bind", 5500, "Base Hotline server port. File transfer port is base port + 1.")
statsPort := flag.String("stats-port", "", "Enable stats HTTP endpoint on address and port")
- configDir := flag.String("config", defaultConfigPath(), "Path to config root")
+ configDir := flag.String("config", configSearchPaths(), "Path to config root")
printVersion := flag.Bool("version", false, "Print version and exit")
logLevel := flag.String("log-level", "info", "Log level")
logFile := flag.String("log-file", "", "Path to log file")
-
init := flag.Bool("init", false, "Populate the config dir with default configuration")
flag.Parse()
}
}
- if _, err := os.Stat(*configDir); os.IsNotExist(err) {
- slogger.Error("Configuration directory not found. Correct the path or re-run with -init to generate initial config.")
- os.Exit(1)
- }
-
config, err := mobius.LoadConfig(path.Join(*configDir, "config.yaml"))
if err != nil {
slogger.Error(fmt.Sprintf("Error loading config: %v", err))
os.Exit(1)
}
- srv, err := hotline.NewServer(*config, *configDir, *netInterface, *basePort, slogger, &hotline.OSFileStore{})
+ srv, err := hotline.NewServer(
+ hotline.WithInterface(*netInterface),
+ hotline.WithLogger(slogger),
+ hotline.WithPort(*basePort),
+ hotline.WithConfig(*config),
+ )
if err != nil {
slogger.Error(fmt.Sprintf("Error starting server: %s", err))
os.Exit(1)
os.Exit(1)
}
- sh := statHandler{hlServer: srv}
+ srv.AccountManager, err = mobius.NewYAMLAccountManager(filepath.Join(*configDir, "Users/"))
+ if err != nil {
+ slogger.Error(fmt.Sprintf("Error loading accounts: %v", err))
+ os.Exit(1)
+ }
+
+ srv.Agreement, err = mobius.NewAgreement(*configDir, "\r")
+ if err != nil {
+ slogger.Error(fmt.Sprintf("Error loading agreement: %v", err))
+ os.Exit(1)
+ }
+
+ bannerPath := filepath.Join(*configDir, config.BannerFile)
+ srv.Banner, err = os.ReadFile(bannerPath)
+ if err != nil {
+ slogger.Error(fmt.Sprintf("Error loading accounts: %v", err))
+ os.Exit(1)
+ }
+
+ reloadFunc := func() {
+ if err := srv.MessageBoard.(*mobius.FlatNews).Reload(); err != nil {
+ slogger.Error("Error reloading news", "err", err)
+ }
+
+ if err := srv.BanList.(*mobius.BanFile).Load(); err != nil {
+ slogger.Error("Error reloading ban list", "err", err)
+ }
+
+ if err := srv.ThreadedNewsMgr.(*mobius.ThreadedNewsYAML).Load(); err != nil {
+ slogger.Error("Error reloading threaded news list", "err", err)
+ }
+
+ if err := srv.Agreement.(*mobius.Agreement).Reload(); err != nil {
+ slogger.Error(fmt.Sprintf("Error reloading agreement: %v", err))
+ os.Exit(1)
+ }
+ }
+
+ reloadHandler := func(reloadFunc func()) func(w http.ResponseWriter, _ *http.Request) {
+ return func(w http.ResponseWriter, _ *http.Request) {
+ reloadFunc()
+
+ _, _ = io.WriteString(w, `{ "msg": "config reloaded" }`)
+ }
+ }
+
+ sh := APIHandler{hlServer: srv}
if *statsPort != "" {
http.HandleFunc("/", sh.RenderStats)
+ http.HandleFunc("/api/v1/stats", sh.RenderStats)
+ http.HandleFunc("/api/v1/reload", reloadHandler(reloadFunc))
go func(srv *hotline.Server) {
- // Use the default DefaultServeMux.
err = http.ListenAndServe(":"+*statsPort, nil)
if err != nil {
log.Fatal(err)
case syscall.SIGHUP:
slogger.Info("SIGHUP received. Reloading configuration.")
- if err := srv.MessageBoard.(*mobius.FlatNews).Reload(); err != nil {
- slogger.Error("Error reloading news", "err", err)
- }
-
- if err := srv.BanList.(*mobius.BanFile).Load(); err != nil {
- slogger.Error("Error reloading ban list", "err", err)
- }
-
- if err := srv.ThreadedNewsMgr.(*mobius.ThreadedNewsYAML).Load(); err != nil {
- slogger.Error("Error reloading threaded news list", "err", err)
- }
+ reloadFunc()
default:
signal.Stop(sigChan)
cancel()
slogger.Info("Hotline server started",
"version", version,
+ "config", *configDir,
"API port", fmt.Sprintf("%s:%v", *netInterface, *basePort),
"Transfer port", fmt.Sprintf("%s:%v", *netInterface, *basePort+1),
)
+ // Assign functions to handle specific Hotline transaction types
+ mobius.RegisterHandlers(srv)
+
// Serve Hotline requests until program exit
log.Fatal(srv.ListenAndServe(ctx))
}
-type statHandler struct {
+type APIHandler struct {
hlServer *hotline.Server
}
-func (sh *statHandler) RenderStats(w http.ResponseWriter, _ *http.Request) {
+func (sh *APIHandler) RenderStats(w http.ResponseWriter, _ *http.Request) {
u, err := json.Marshal(sh.hlServer.CurrentStats())
if err != nil {
panic(err)
_, _ = io.WriteString(w, string(u))
}
-func defaultConfigPath() string {
- var cfgPath string
-
- switch runtime.GOOS {
- case "windows":
- cfgPath = "config/"
- case "darwin":
- if _, err := os.Stat("/usr/local/var/mobius/config/"); err == nil {
- cfgPath = "/usr/local/var/mobius/config/"
- } else if _, err := os.Stat("/opt/homebrew/var/mobius/config"); err == nil {
- cfgPath = "/opt/homebrew/var/mobius/config/"
+func configSearchPaths() string {
+ for _, cfgPath := range mobius.ConfigSearchOrder {
+ if _, err := os.Stat(cfgPath); err == nil {
+ return cfgPath
}
- case "linux":
- cfgPath = "/usr/local/var/mobius/config/"
- default:
- cfgPath = "./config/"
}
- return cfgPath
+ return "config"
}
// copyDir recursively copies a directory tree, attempting to preserve permissions.
if err != nil {
return err
}
- f.Close()
+ _ = f.Close()
}
} else {
f, err := os.Create(path.Join(dst, dirEntry.Name()))
if err != nil {
return err
}
- f.Close()
+ _ = f.Close()
}
}
-Welcome to Hotline
+From Adam Hinkley (Nov04 12:05):
+
+Welcome to...
+ _ _ _ _ _
+| | | | ___ | |_| (_)_ __ ___
+| |_| |/ _ \| __| | | '_ \ / _ \
+| _ | (_) | |_| | | | | | __/
+|_| |_|\___/ \__|_|_|_| |_|\___|
+
+This is the NEWS area of Hotline. Users can leave messages in this area by clicking on the “Post” button in the toolbar.
+
+The messages stay here until the administrator cleans the News up. To edit the news, simply open the text file named “News” in the same folder as the Hotline Server program. Once you've finished editing it, click the “Reload News” button in the server.
+__________________________________________________________
\ No newline at end of file
go 1.22
require (
- github.com/go-playground/validator/v10 v10.19.0
+ github.com/go-playground/validator/v10 v10.22.0
github.com/stretchr/testify v1.9.0
- golang.org/x/crypto v0.22.0
- golang.org/x/text v0.14.0
+ golang.org/x/crypto v0.25.0
+ golang.org/x/text v0.16.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
- github.com/gabriel-vasile/mimetype v1.4.3 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.4 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
- golang.org/x/net v0.24.0 // indirect
- golang.org/x/sys v0.19.0 // indirect
+ golang.org/x/net v0.27.0 // indirect
+ golang.org/x/sys v0.22.0 // indirect
)
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
-github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
+github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
+github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
-github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
+github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
-golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
-golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
-golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
-golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
-golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
-golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
-golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
+golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
+golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
+golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
+golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
+golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
AccessSendPrivMsg = 40 // Messaging: Can Send Messages (Note: 1.9 protocol doc incorrectly says this is bit 19)
)
-type accessBitmap [8]byte
+type AccessBitmap [8]byte
-func (bits *accessBitmap) Set(i int) {
+func (bits *AccessBitmap) Set(i int) {
bits[i/8] |= 1 << uint(7-i%8)
}
-func (bits *accessBitmap) IsSet(i int) bool {
+func (bits *AccessBitmap) IsSet(i int) bool {
return bits[i/8]&(1<<uint(7-i%8)) != 0
}
}
tests := []struct {
name string
- bits accessBitmap
+ bits AccessBitmap
args args
want bool
}{
{
name: "returns true when bit is set",
- bits: func() (access accessBitmap) {
+ bits: func() (access AccessBitmap) {
access.Set(22)
return access
}(),
},
{
name: "returns false when bit is unset",
- bits: accessBitmap{},
+ bits: AccessBitmap{},
args: args{i: 22},
want: false,
},
Login string `yaml:"Login"`
Name string `yaml:"Name"`
Password string `yaml:"Password"`
- Access accessBitmap `yaml:"Access,flow"`
+ Access AccessBitmap `yaml:"Access,flow"`
readOffset int // Internal offset to track read progress
}
-func NewAccount(login, name, password string, access accessBitmap) *Account {
+func NewAccount(login, name, password string, access AccessBitmap) *Account {
return &Account{
Login: login,
Name: name,
- Password: hashAndSalt([]byte(password)),
+ Password: HashAndSalt([]byte(password)),
Access: access,
}
}
func (a *Account) Read(p []byte) (int, error) {
fields := []Field{
NewField(FieldUserName, []byte(a.Name)),
- NewField(FieldUserLogin, encodeString([]byte(a.Login))),
+ NewField(FieldUserLogin, EncodeString([]byte(a.Login))),
NewField(FieldUserAccess, a.Access[:]),
}
return n, nil
}
-// hashAndSalt generates a password hash from a users obfuscated plaintext password
-func hashAndSalt(pwd []byte) string {
+// HashAndSalt generates a password hash from a users obfuscated plaintext password
+func HashAndSalt(pwd []byte) string {
hash, _ := bcrypt.GenerateFromPassword(pwd, bcrypt.MinCost)
return string(hash)
package hotline
-import (
- "fmt"
- "github.com/stretchr/testify/mock"
- "gopkg.in/yaml.v3"
- "os"
- "path"
- "path/filepath"
- "sync"
-)
-
type AccountManager interface {
Create(account Account) error
Update(account Account, newLogin string) error
List() []Account
Delete(login string) error
}
-
-type YAMLAccountManager struct {
- accounts map[string]Account
- accountDir string
-
- mu sync.Mutex
-}
-
-func NewYAMLAccountManager(accountDir string) (*YAMLAccountManager, error) {
- accountMgr := YAMLAccountManager{
- accountDir: accountDir,
- accounts: make(map[string]Account),
- }
-
- matches, err := filepath.Glob(filepath.Join(accountDir, "*.yaml"))
- if err != nil {
- return nil, err
- }
-
- if len(matches) == 0 {
- return nil, fmt.Errorf("no accounts found in directory: %s", accountDir)
- }
-
- for _, file := range matches {
- var account Account
- if err = loadFromYAMLFile(file, &account); err != nil {
- return nil, fmt.Errorf("error loading account %s: %w", file, err)
- }
-
- accountMgr.accounts[account.Login] = account
- }
-
- return &accountMgr, nil
-}
-
-func (am *YAMLAccountManager) Create(account Account) error {
- am.mu.Lock()
- defer am.mu.Unlock()
-
- // Create account file, returning an error if one already exists.
- file, err := os.OpenFile(
- filepath.Join(am.accountDir, path.Join("/", account.Login+".yaml")),
- os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644,
- )
- if err != nil {
- return fmt.Errorf("error creating account file: %w", err)
- }
- defer file.Close()
-
- b, err := yaml.Marshal(account)
- if err != nil {
- return fmt.Errorf("marshal account to YAML: %v", err)
- }
-
- _, err = file.Write(b)
- if err != nil {
- return fmt.Errorf("write account file: %w", err)
- }
-
- am.accounts[account.Login] = account
-
- return nil
-}
-
-func (am *YAMLAccountManager) Update(account Account, newLogin string) error {
- am.mu.Lock()
- defer am.mu.Unlock()
-
- // If the login has changed, rename the account file.
- if account.Login != newLogin {
- err := os.Rename(
- filepath.Join(am.accountDir, path.Join("/", account.Login)+".yaml"),
- filepath.Join(am.accountDir, path.Join("/", newLogin)+".yaml"),
- )
- if err != nil {
- return fmt.Errorf("error renaming account file: %w", err)
- }
-
- account.Login = newLogin
- am.accounts[newLogin] = account
-
- delete(am.accounts, account.Login)
- }
-
- out, err := yaml.Marshal(&account)
- if err != nil {
- return err
- }
-
- if err := os.WriteFile(filepath.Join(am.accountDir, newLogin+".yaml"), out, 0644); err != nil {
- return fmt.Errorf("error writing account file: %w", err)
- }
-
- am.accounts[account.Login] = account
-
- return nil
-}
-
-func (am *YAMLAccountManager) Get(login string) *Account {
- am.mu.Lock()
- defer am.mu.Unlock()
-
- account, ok := am.accounts[login]
- if !ok {
- return nil
- }
-
- return &account
-}
-
-func (am *YAMLAccountManager) List() []Account {
- am.mu.Lock()
- defer am.mu.Unlock()
-
- var accounts []Account
- for _, account := range am.accounts {
- accounts = append(accounts, account)
- }
-
- return accounts
-}
-
-func (am *YAMLAccountManager) Delete(login string) error {
- am.mu.Lock()
- defer am.mu.Unlock()
-
- err := os.Remove(filepath.Join(am.accountDir, path.Join("/", login+".yaml")))
- if err != nil {
- return fmt.Errorf("delete account file: %v", err)
- }
-
- delete(am.accounts, login)
-
- return nil
-}
-
-type MockAccountManager struct {
- mock.Mock
-}
-
-func (m *MockAccountManager) Create(account Account) error {
- args := m.Called(account)
-
- return args.Error(0)
-}
-
-func (m *MockAccountManager) Update(account Account, newLogin string) error {
- args := m.Called(account, newLogin)
-
- return args.Error(0)
-}
-
-func (m *MockAccountManager) Get(login string) *Account {
- args := m.Called(login)
-
- return args.Get(0).(*Account)
-}
-
-func (m *MockAccountManager) List() []Account {
- args := m.Called()
-
- return args.Get(0).([]Account)
-}
-
-func (m *MockAccountManager) Delete(login string) error {
- args := m.Called(login)
-
- return args.Error(0)
-}
import "time"
-const tempBanDuration = 30 * time.Minute
+// BanDuration is the length of time for temporary bans.
+const BanDuration = 30 * time.Minute
type BanMgr interface {
Add(ip string, until *time.Time) error
Members(id ChatID) []*ClientConn
}
-type MockChatManager struct {
- mock.Mock
-}
-
-func (m *MockChatManager) New(cc *ClientConn) ChatID {
- args := m.Called(cc)
-
- return args.Get(0).(ChatID)
-}
-
-func (m *MockChatManager) GetSubject(id ChatID) string {
- args := m.Called(id)
-
- return args.String(0)
-}
-
-func (m *MockChatManager) Join(id ChatID, cc *ClientConn) {
- m.Called(id, cc)
-}
-
-func (m *MockChatManager) Leave(id ChatID, clientID [2]byte) {
- m.Called(id, clientID)
-}
-
-func (m *MockChatManager) SetSubject(id ChatID, subject string) {
- m.Called(id, subject)
-
-}
-
-func (m *MockChatManager) Members(id ChatID) []*ClientConn {
- args := m.Called(id)
-
- return args.Get(0).([]*ClientConn)
-}
-
type MemChatManager struct {
chats map[ChatID]*PrivateChat
chat.Subject = subject
}
+
+type MockChatManager struct {
+ mock.Mock
+}
+
+func (m *MockChatManager) New(cc *ClientConn) ChatID {
+ args := m.Called(cc)
+
+ return args.Get(0).(ChatID)
+}
+
+func (m *MockChatManager) GetSubject(id ChatID) string {
+ args := m.Called(id)
+
+ return args.String(0)
+}
+
+func (m *MockChatManager) Join(id ChatID, cc *ClientConn) {
+ m.Called(id, cc)
+}
+
+func (m *MockChatManager) Leave(id ChatID, clientID [2]byte) {
+ m.Called(id, clientID)
+}
+
+func (m *MockChatManager) SetSubject(id ChatID, subject string) {
+ m.Called(id, subject)
+
+}
+
+func (m *MockChatManager) Members(id ChatID) []*ClientConn {
+ args := m.Called(id)
+
+ return args.Get(0).([]*ClientConn)
+}
TranLogin, [2]byte{0, 0},
NewField(FieldUserName, []byte(c.Pref.Username)),
NewField(FieldUserIconID, c.Pref.IconBytes()),
- NewField(FieldUserLogin, encodeString([]byte(login))),
- NewField(FieldUserPassword, encodeString([]byte(passwd))),
+ NewField(FieldUserLogin, EncodeString([]byte(login))),
+ NewField(FieldUserPassword, EncodeString([]byte(passwd))),
),
)
if err != nil {
return err
}
}
- } else {
- c.Logger.Debug(
- "Unimplemented transaction type",
- "IsReply", t.IsReply,
- "type", t.Type[:],
- )
}
return nil
Icon []byte // TODO: make fixed size of 2
Version []byte // TODO: make fixed size of 2
- flagsMU sync.Mutex // TODO: move into UserFlags struct
+ FlagsMU sync.Mutex // TODO: move into UserFlags struct
Flags UserFlags
UserName []byte
ClientFileTransferMgr ClientFileTransferMgr
- logger *slog.Logger
+ Logger *slog.Logger
mu sync.RWMutex
}
cftm.mu.Lock()
defer cftm.mu.Unlock()
- cftm.transfers[ftType][ft.refNum] = ft
+ cftm.transfers[ftType][ft.RefNum] = ft
}
func (cftm *ClientFileTransferMgr) Get(ftType FileTransferType) []FileTransfer {
}
func (cc *ClientConn) handleTransaction(transaction Transaction) {
- if handler, ok := TransactionHandlers[transaction.Type]; ok {
+ if handler, ok := cc.Server.handlers[transaction.Type]; ok {
if transaction.Type != TranKeepAlive {
- cc.logger.Info(tranTypeNames[transaction.Type])
+ cc.Logger.Info(tranTypeNames[transaction.Type])
}
for _, t := range handler(cc, &transaction) {
return cc.Account.Access.IsSet(access)
}
-// Disconnect notifies other clients that a client has disconnected
+// Disconnect notifies other clients that a client has disconnected and closes the connection.
func (cc *ClientConn) Disconnect() {
cc.Server.ClientMgr.Delete(cc.ID)
}
if err := cc.Connection.Close(); err != nil {
- cc.Server.Logger.Error("error closing client connection", "RemoteAddr", cc.RemoteAddr)
+ cc.Server.Logger.Debug("error closing client connection", "RemoteAddr", cc.RemoteAddr)
}
}
func (cc *ClientConn) NotifyOthers(t Transaction) (trans []Transaction) {
for _, c := range cc.Server.ClientMgr.List() {
if c.ID != cc.ID {
- t.clientID = c.ID
+ t.ClientID = c.ID
trans = append(trans, t)
}
}
return Transaction{
IsReply: 1,
ID: t.ID,
- clientID: cc.ID,
+ ClientID: cc.ID,
Fields: fields,
}
}
func (cc *ClientConn) NewErrReply(t *Transaction, errMsg string) []Transaction {
return []Transaction{
{
- clientID: cc.ID,
+ ClientID: cc.ID,
IsReply: 1,
ID: t.ID,
ErrorCode: [4]byte{0, 0, 0, 1},
package hotline
-const (
- userIdleSeconds = 300 // time in seconds before an inactive user is marked idle
- idleCheckInterval = 10 // time in seconds to check for idle users
- trackerUpdateFrequency = 300 // time in seconds between tracker re-registration
-)
-
type Config struct {
Name string `yaml:"Name" validate:"required,max=50"` // Name used for Tracker registration
Description string `yaml:"Description" validate:"required,max=200"` // Description used for Tracker registration
- BannerFile string `yaml:"BannerFile"` // Path to banner jpg
+ BannerFile string `yaml:"BannerFile"` // Path to Banner jpg
FileRoot string `yaml:"FileRoot" validate:"required"` // Path to Files
EnableTrackerRegistration bool `yaml:"EnableTrackerRegistration"` // Toggle Tracker Registration
Trackers []string `yaml:"Trackers" validate:"dive,hostname_port"` // List of trackers that the server should register with
--- /dev/null
+// Package hotline provides Hotline client and server implementations.
+package hotline
return f
}
-// fieldScanner implements bufio.SplitFunc for parsing byte slices into complete tokens
-func fieldScanner(data []byte, _ bool) (advance int, token []byte, err error) {
+// FieldScanner implements bufio.SplitFunc for parsing byte slices into complete tokens
+func FieldScanner(data []byte, _ bool) (advance int, token []byte, err error) {
if len(data) < minFieldLen {
return 0, nil, nil
}
}
func (f *Field) DecodeObfuscatedString() string {
- return string(encodeString(f.Data))
+ return string(EncodeString(f.Data))
}
// DecodeNewsPath decodes the field data to a news path.
return minFieldLen + dataSize, nil
}
-func getField(id [2]byte, fields *[]Field) *Field {
+func GetField(id [2]byte, fields *[]Field) *Field {
for _, field := range *fields {
if id == field.Type {
return &field
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- gotAdvance, gotToken, err := fieldScanner(tt.args.data, tt.args.in1)
- if !tt.wantErr(t, err, fmt.Sprintf("fieldScanner(%v, %v)", tt.args.data, tt.args.in1)) {
+ gotAdvance, gotToken, err := FieldScanner(tt.args.data, tt.args.in1)
+ if !tt.wantErr(t, err, fmt.Sprintf("FieldScanner(%v, %v)", tt.args.data, tt.args.in1)) {
return
}
- assert.Equalf(t, tt.wantAdvance, gotAdvance, "fieldScanner(%v, %v)", tt.args.data, tt.args.in1)
- assert.Equalf(t, tt.wantToken, gotToken, "fieldScanner(%v, %v)", tt.args.data, tt.args.in1)
+ assert.Equalf(t, tt.wantAdvance, gotAdvance, "FieldScanner(%v, %v)", tt.args.data, tt.args.in1)
+ assert.Equalf(t, tt.wantToken, gotToken, "FieldScanner(%v, %v)", tt.args.data, tt.args.in1)
})
}
}
+++ /dev/null
-package hotline
-
-import (
- "encoding/binary"
- "io"
- "slices"
-)
-
-type FileHeader struct {
- Size [2]byte // Total size of FileHeader payload
- Type [2]byte // 0 for file, 1 for dir
- FilePath []byte // encoded file path
-
- readOffset int // Internal offset to track read progress
-}
-
-func NewFileHeader(fileName string, isDir bool) FileHeader {
- fh := FileHeader{
- FilePath: EncodeFilePath(fileName),
- }
- if isDir {
- fh.Type = [2]byte{0x00, 0x01}
- }
-
- encodedPathLen := uint16(len(fh.FilePath) + len(fh.Type))
- binary.BigEndian.PutUint16(fh.Size[:], encodedPathLen)
-
- return fh
-}
-
-func (fh *FileHeader) Read(p []byte) (int, error) {
- buf := slices.Concat(
- fh.Size[:],
- fh.Type[:],
- fh.FilePath,
- )
-
- if fh.readOffset >= len(buf) {
- return 0, io.EOF // All bytes have been read
- }
-
- n := copy(p, buf[fh.readOffset:])
- fh.readOffset += n
-
- return n, nil
-}
+++ /dev/null
-package hotline
-
-import (
- "io"
- "reflect"
- "testing"
-)
-
-func TestNewFileHeader(t *testing.T) {
- type args struct {
- fileName string
- isDir bool
- }
- tests := []struct {
- name string
- args args
- want FileHeader
- }{
- {
- name: "when path is file",
- args: args{
- fileName: "foo",
- isDir: false,
- },
- want: FileHeader{
- Size: [2]byte{0x00, 0x0a},
- Type: [2]byte{0x00, 0x00},
- FilePath: EncodeFilePath("foo"),
- },
- },
- {
- name: "when path is dir",
- args: args{
- fileName: "foo",
- isDir: true,
- },
- want: FileHeader{
- Size: [2]byte{0x00, 0x0a},
- Type: [2]byte{0x00, 0x01},
- FilePath: EncodeFilePath("foo"),
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := NewFileHeader(tt.args.fileName, tt.args.isDir); !reflect.DeepEqual(got, tt.want) {
- t.Errorf("NewFileHeader() = %v, want %v", got, tt.want)
- }
- })
- }
-}
-
-func TestFileHeader_Payload(t *testing.T) {
- type fields struct {
- Size [2]byte
- Type [2]byte
- FilePath []byte
- }
- tests := []struct {
- name string
- fields fields
- want []byte
- }{
- {
- name: "has expected payload bytes",
- fields: fields{
- Size: [2]byte{0x00, 0x0a},
- Type: [2]byte{0x00, 0x00},
- FilePath: EncodeFilePath("foo"),
- },
- want: []byte{
- 0x00, 0x0a, // total size
- 0x00, 0x00, // type
- 0x00, 0x01, // path item count
- 0x00, 0x00, // path separator
- 0x03, // pathName len
- 0x66, 0x6f, 0x6f, // "foo"
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- fh := &FileHeader{
- Size: tt.fields.Size,
- Type: tt.fields.Type,
- FilePath: tt.fields.FilePath,
- }
- got, _ := io.ReadAll(fh)
- if !reflect.DeepEqual(got, tt.want) {
- t.Errorf("Read() = %v, want %v", got, tt.want)
- }
- })
- }
-}
)
type FileNameWithInfo struct {
- fileNameWithInfoHeader
+ FileNameWithInfoHeader
Name []byte // File Name
readOffset int // Internal offset to track read progress
}
-// fileNameWithInfoHeader contains the fixed length fields of FileNameWithInfo
-type fileNameWithInfoHeader struct {
+// FileNameWithInfoHeader contains the fixed length fields of FileNameWithInfo
+type FileNameWithInfoHeader struct {
Type [4]byte // File type code
Creator [4]byte // File creator code
FileSize [4]byte // File Size in bytes
NameSize [2]byte // Length of Name field
}
-func (f *fileNameWithInfoHeader) nameLen() int {
+func (f *FileNameWithInfoHeader) nameLen() int {
return int(binary.BigEndian.Uint16(f.NameSize[:]))
}
}
func (f *FileNameWithInfo) Write(p []byte) (int, error) {
- err := binary.Read(bytes.NewReader(p), binary.BigEndian, &f.fileNameWithInfoHeader)
+ err := binary.Read(bytes.NewReader(p), binary.BigEndian, &f.FileNameWithInfoHeader)
if err != nil {
return 0, err
}
- headerLen := binary.Size(f.fileNameWithInfoHeader)
+ headerLen := binary.Size(f.FileNameWithInfoHeader)
f.Name = p[headerLen : headerLen+f.nameLen()]
return len(p), nil
func TestFileNameWithInfo_MarshalBinary(t *testing.T) {
type fields struct {
- fileNameWithInfoHeader fileNameWithInfoHeader
+ fileNameWithInfoHeader FileNameWithInfoHeader
name []byte
}
tests := []struct {
{
name: "returns expected bytes",
fields: fields{
- fileNameWithInfoHeader: fileNameWithInfoHeader{
+ fileNameWithInfoHeader: FileNameWithInfoHeader{
Type: [4]byte{0x54, 0x45, 0x58, 0x54}, // TEXT
Creator: [4]byte{0x54, 0x54, 0x58, 0x54}, // TTXT
FileSize: [4]byte{0x00, 0x43, 0x16, 0xd3}, // File Size
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := &FileNameWithInfo{
- fileNameWithInfoHeader: tt.fields.fileNameWithInfoHeader,
+ FileNameWithInfoHeader: tt.fields.fileNameWithInfoHeader,
Name: tt.fields.name,
}
gotData, err := io.ReadAll(f)
func TestFileNameWithInfo_UnmarshalBinary(t *testing.T) {
type fields struct {
- fileNameWithInfoHeader fileNameWithInfoHeader
+ fileNameWithInfoHeader FileNameWithInfoHeader
name []byte
}
type args struct {
},
},
want: &FileNameWithInfo{
- fileNameWithInfoHeader: fileNameWithInfoHeader{
+ FileNameWithInfoHeader: FileNameWithInfoHeader{
Type: [4]byte{0x54, 0x45, 0x58, 0x54}, // TEXT
Creator: [4]byte{0x54, 0x54, 0x58, 0x54}, // TTXT
FileSize: [4]byte{0x00, 0x43, 0x16, 0xd3}, // File Size
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := &FileNameWithInfo{
- fileNameWithInfoHeader: tt.fields.fileNameWithInfoHeader,
+ FileNameWithInfoHeader: tt.fields.fileNameWithInfoHeader,
Name: tt.fields.name,
}
if _, err := f.Write(tt.args.data); (err != nil) != tt.wantErr {
return binary.BigEndian.Uint16(fp.ItemCount[:])
}
-func readPath(fileRoot string, filePath, fileName []byte) (fullPath string, err error) {
+func ReadPath(fileRoot string, filePath, fileName []byte) (fullPath string, err error) {
var fp FilePath
if filePath != nil {
if _, err = fp.Write(filePath); err != nil {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- got, err := readPath(tt.args.fileRoot, tt.args.filePath, tt.args.fileName)
+ got, err := ReadPath(tt.args.fileRoot, tt.args.filePath, tt.args.fileName)
if (err != nil) != tt.wantErr {
- t.Errorf("readPath() error = %v, wantErr %v", err, tt.wantErr)
+ t.Errorf("ReadPath() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
- t.Errorf("readPath() got = %v, want %v", got, tt.want)
+ t.Errorf("ReadPath() got = %v, want %v", got, tt.want)
}
})
}
"math"
"os"
"path/filepath"
+ "slices"
"strings"
"sync"
)
ftm.mu.Lock()
defer ftm.mu.Unlock()
- _, _ = rand.Read(ft.refNum[:])
+ _, _ = rand.Read(ft.RefNum[:])
- ftm.fileTransfers[ft.refNum] = ft
+ ftm.fileTransfers[ft.RefNum] = ft
ft.ClientConn.ClientFileTransferMgr.Add(ft.Type, ft)
-
- //ft.ClientConn.transfersMU.Lock()
- //ft.ClientConn.transfers[ft.Type] = ft
- //ft.ClientConn.transfersMU.Unlock()
}
func (ftm *MemFileTransferMgr) Get(id FileTransferID) *FileTransfer {
ft := ftm.fileTransfers[id]
- //ft.ClientConn.transfersMU.Lock()
- //delete(ft.ClientConn.transfers[ft.Type], ft.refNum)
- //ft.ClientConn.transfersMU.Unlock()
ft.ClientConn.ClientFileTransferMgr.Delete(ft.Type, id)
delete(ftm.fileTransfers, id)
type FileTransfer struct {
FileName []byte
FilePath []byte
- refNum [4]byte
+ RefNum [4]byte
Type FileTransferType
TransferSize []byte
FolderItemCount []byte
- fileResumeData *FileResumeData
- options []byte
+ FileResumeData *FileResumeData
+ Options []byte
bytesSentCounter *WriteCounter
ClientConn *ClientConn
}
return n, nil
}
-func (cc *ClientConn) newFileTransfer(transferType FileTransferType, fileName, filePath, size []byte) *FileTransfer {
+func (cc *ClientConn) NewFileTransfer(transferType FileTransferType, fileName, filePath, size []byte) *FileTransfer {
ft := &FileTransfer{
FileName: fileName,
FilePath: filePath,
ClientConn: cc,
bytesSentCounter: &WriteCounter{},
}
- //
- //_, _ = rand.Read(ft.refNum[:])
- //
- //cc.transfersMU.Lock()
- //defer cc.transfersMU.Unlock()
- //cc.transfers[transferType][ft.refNum] = ft
cc.Server.FileTransferMgr.Add(ft)
- //cc.Server.mux.Lock()
- //defer cc.Server.mux.Unlock()
- //cc.Server.fileTransfers[ft.refNum] = ft
-
return ft
}
return filepath.Join(pathSegments...)
}
+type FileHeader struct {
+ Size [2]byte // Total size of FileHeader payload
+ Type [2]byte // 0 for file, 1 for dir
+ FilePath []byte // encoded file path
+
+ readOffset int // Internal offset to track read progress
+}
+
+func NewFileHeader(fileName string, isDir bool) FileHeader {
+ fh := FileHeader{
+ FilePath: EncodeFilePath(fileName),
+ }
+ if isDir {
+ fh.Type = [2]byte{0x00, 0x01}
+ }
+
+ encodedPathLen := uint16(len(fh.FilePath) + len(fh.Type))
+ binary.BigEndian.PutUint16(fh.Size[:], encodedPathLen)
+
+ return fh
+}
+
+func (fh *FileHeader) Read(p []byte) (int, error) {
+ buf := slices.Concat(
+ fh.Size[:],
+ fh.Type[:],
+ fh.FilePath,
+ )
+
+ if fh.readOffset >= len(buf) {
+ return 0, io.EOF // All bytes have been read
+ }
+
+ n := copy(p, buf[fh.readOffset:])
+ fh.readOffset += n
+
+ return n, nil
+}
+
func DownloadHandler(w io.Writer, fullPath string, fileTransfer *FileTransfer, fs FileStore, rLogger *slog.Logger, preserveForks bool) error {
//s.Stats.DownloadCounter += 1
//s.Stats.DownloadsInProgress += 1
//}()
var dataOffset int64
- if fileTransfer.fileResumeData != nil {
- dataOffset = int64(binary.BigEndian.Uint32(fileTransfer.fileResumeData.ForkInfoList[0].DataSize[:]))
+ if fileTransfer.FileResumeData != nil {
+ dataOffset = int64(binary.BigEndian.Uint32(fileTransfer.FileResumeData.ForkInfoList[0].DataSize[:]))
}
- fw, err := newFileWrapper(fs, fullPath, 0)
+ fw, err := NewFileWrapper(fs, fullPath, 0)
if err != nil {
return fmt.Errorf("reading file header: %v", err)
}
// If file transfer options are included, that means this is a "quick preview" request. In this case skip sending
// the flat file info and proceed directly to sending the file data.
- if fileTransfer.options == nil {
- if _, err = io.Copy(w, fw.ffo); err != nil {
+ if fileTransfer.Options == nil {
+ if _, err = io.Copy(w, fw.Ffo); err != nil {
return fmt.Errorf("send flat file object: %v", err)
}
}
}
// If the client requested to resume transfer, do not send the resource fork header.
- if fileTransfer.fileResumeData == nil {
+ if fileTransfer.FileResumeData == nil {
err = binary.Write(w, binary.BigEndian, fw.rsrcForkHeader())
if err != nil {
return fmt.Errorf("send resource fork header: %v", err)
}
if errors.Is(err, fs.ErrNotExist) {
// If not found, open or create a new .incomplete file
- file, err = os.OpenFile(fullPath+incompleteFileSuffix, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
+ file, err = os.OpenFile(fullPath+IncompleteFileSuffix, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return err
}
}
- f, err := newFileWrapper(fileStore, fullPath, 0)
+ f, err := NewFileWrapper(fileStore, fullPath, 0)
if err != nil {
return err
}
return err
}
- iForkWriter, err = f.infoForkWriter()
+ iForkWriter, err = f.InfoForkWriter()
if err != nil {
return err
}
return nil
}
- hlFile, err := newFileWrapper(fileStore, path, 0)
+ hlFile, err := NewFileWrapper(fileStore, path, 0)
if err != nil {
return err
}
rLogger.Info("File download started",
"fileName", info.Name(),
- "TransferSize", fmt.Sprintf("%x", hlFile.ffo.TransferSize(dataOffset)),
+ "TransferSize", fmt.Sprintf("%x", hlFile.Ffo.TransferSize(dataOffset)),
)
// Send file size to client
- if _, err := rwc.Write(hlFile.ffo.TransferSize(dataOffset)); err != nil {
+ if _, err := rwc.Write(hlFile.Ffo.TransferSize(dataOffset)); err != nil {
rLogger.Error(err.Error())
return fmt.Errorf("error sending file size: %w", err)
}
// Send ffo bytes to client
- _, err = io.Copy(rwc, hlFile.ffo)
+ _, err = io.Copy(rwc, hlFile.Ffo)
if err != nil {
return fmt.Errorf("error sending flat file object: %w", err)
}
return fmt.Errorf("error sending file: %w", err)
}
- if nextAction[1] != 2 && hlFile.ffo.FlatFileHeader.ForkCount[1] == 3 {
+ if nextAction[1] != 2 && hlFile.Ffo.FlatFileHeader.ForkCount[1] == 3 {
err = binary.Write(rwc, binary.BigEndian, hlFile.rsrcForkHeader())
if err != nil {
return fmt.Errorf("error sending resource fork header: %w", err)
}
// Check if we have a partial file already. If so, send dlFldrAction_ResumeFile to client to resume upload.
- incompleteFile, err := os.Stat(filepath.Join(fullPath, fu.FormattedPath()+incompleteFileSuffix))
+ incompleteFile, err := os.Stat(filepath.Join(fullPath, fu.FormattedPath()+IncompleteFileSuffix))
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
offset := make([]byte, 4)
binary.BigEndian.PutUint32(offset, uint32(incompleteFile.Size()))
- file, err := os.OpenFile(fullPath+"/"+fu.FormattedPath()+incompleteFileSuffix, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
+ file, err := os.OpenFile(fullPath+"/"+fu.FormattedPath()+IncompleteFileSuffix, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
filePath := filepath.Join(fullPath, fu.FormattedPath())
- hlFile, err := newFileWrapper(fileStore, filePath, 0)
+ hlFile, err := NewFileWrapper(fileStore, filePath, 0)
if err != nil {
return err
}
rForkWriter := io.Discard
iForkWriter := io.Discard
if preserveForks {
- iForkWriter, err = hlFile.infoForkWriter()
+ iForkWriter, err = hlFile.InfoForkWriter()
if err != nil {
return err
}
import (
"encoding/binary"
"github.com/stretchr/testify/assert"
+ "io"
+ "reflect"
"testing"
)
ft := &FileTransfer{
FileName: tt.fields.FileName,
FilePath: tt.fields.FilePath,
- refNum: tt.fields.refNum,
+ RefNum: tt.fields.refNum,
Type: tt.fields.Type,
TransferSize: tt.fields.TransferSize,
FolderItemCount: tt.fields.FolderItemCount,
- fileResumeData: tt.fields.fileResumeData,
- options: tt.fields.options,
+ FileResumeData: tt.fields.fileResumeData,
+ Options: tt.fields.options,
bytesSentCounter: tt.fields.bytesSentCounter,
ClientConn: tt.fields.ClientConn,
}
})
}
}
+
+func TestNewFileHeader(t *testing.T) {
+ type args struct {
+ fileName string
+ isDir bool
+ }
+ tests := []struct {
+ name string
+ args args
+ want FileHeader
+ }{
+ {
+ name: "when path is file",
+ args: args{
+ fileName: "foo",
+ isDir: false,
+ },
+ want: FileHeader{
+ Size: [2]byte{0x00, 0x0a},
+ Type: [2]byte{0x00, 0x00},
+ FilePath: EncodeFilePath("foo"),
+ },
+ },
+ {
+ name: "when path is dir",
+ args: args{
+ fileName: "foo",
+ isDir: true,
+ },
+ want: FileHeader{
+ Size: [2]byte{0x00, 0x0a},
+ Type: [2]byte{0x00, 0x01},
+ FilePath: EncodeFilePath("foo"),
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := NewFileHeader(tt.args.fileName, tt.args.isDir); !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("NewFileHeader() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestFileHeader_Payload(t *testing.T) {
+ type fields struct {
+ Size [2]byte
+ Type [2]byte
+ FilePath []byte
+ }
+ tests := []struct {
+ name string
+ fields fields
+ want []byte
+ }{
+ {
+ name: "has expected payload bytes",
+ fields: fields{
+ Size: [2]byte{0x00, 0x0a},
+ Type: [2]byte{0x00, 0x00},
+ FilePath: EncodeFilePath("foo"),
+ },
+ want: []byte{
+ 0x00, 0x0a, // total size
+ 0x00, 0x00, // type
+ 0x00, 0x01, // path item count
+ 0x00, 0x00, // path separator
+ 0x03, // pathName len
+ 0x66, 0x6f, 0x6f, // "foo"
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ fh := &FileHeader{
+ Size: tt.fields.Size,
+ Type: tt.fields.Type,
+ FilePath: tt.fields.FilePath,
+ }
+ got, _ := io.ReadAll(fh)
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("Read() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
)
const (
- incompleteFileSuffix = ".incomplete"
- infoForkNameTemplate = ".info_%s" // template string for info fork filenames
- rsrcForkNameTemplate = ".rsrc_%s" // template string for resource fork filenames
+ IncompleteFileSuffix = ".incomplete"
+ InfoForkNameTemplate = ".info_%s" // template string for info fork filenames
+ RsrcForkNameTemplate = ".rsrc_%s" // template string for resource fork filenames
)
// fileWrapper encapsulates the data, info, and resource forks of a Hotline file and provides methods to manage the files.
type fileWrapper struct {
fs FileStore
- name string // name of the file
+ Name string // Name of the file
path string // path to file directory
dataPath string // path to the file data fork
dataOffset int64
rsrcPath string // path to the file resource fork
infoPath string // path to the file information fork
incompletePath string // path to partially transferred temp file
- ffo *flattenedFileObject
+ Ffo *flattenedFileObject
}
-func newFileWrapper(fs FileStore, path string, dataOffset int64) (*fileWrapper, error) {
+func NewFileWrapper(fs FileStore, path string, dataOffset int64) (*fileWrapper, error) {
dir := filepath.Dir(path)
fName := filepath.Base(path)
f := fileWrapper{
fs: fs,
- name: fName,
+ Name: fName,
path: dir,
dataPath: path,
dataOffset: dataOffset,
- rsrcPath: filepath.Join(dir, fmt.Sprintf(rsrcForkNameTemplate, fName)),
- infoPath: filepath.Join(dir, fmt.Sprintf(infoForkNameTemplate, fName)),
- incompletePath: filepath.Join(dir, fName+incompleteFileSuffix),
- ffo: &flattenedFileObject{},
+ rsrcPath: filepath.Join(dir, fmt.Sprintf(RsrcForkNameTemplate, fName)),
+ infoPath: filepath.Join(dir, fmt.Sprintf(InfoForkNameTemplate, fName)),
+ incompletePath: filepath.Join(dir, fName+IncompleteFileSuffix),
+ Ffo: &flattenedFileObject{},
}
var err error
- f.ffo, err = f.flattenedFileObject()
+ f.Ffo, err = f.flattenedFileObject()
if err != nil {
return nil, err
}
return &f, nil
}
-func (f *fileWrapper) totalSize() []byte {
+func (f *fileWrapper) TotalSize() []byte {
var s int64
size := make([]byte, 4)
func (f *fileWrapper) rsrcForkHeader() FlatFileForkHeader {
return FlatFileForkHeader{
- ForkType: [4]byte{0x4D, 0x41, 0x43, 0x52}, // "MACR"
- CompressionType: [4]byte{},
- RSVD: [4]byte{},
- DataSize: f.rsrcForkSize(),
+ ForkType: [4]byte{0x4D, 0x41, 0x43, 0x52}, // "MACR"
+ DataSize: f.rsrcForkSize(),
}
}
func (f *fileWrapper) incompleteDataName() string {
- return f.name + incompleteFileSuffix
+ return f.Name + IncompleteFileSuffix
}
func (f *fileWrapper) rsrcForkName() string {
- return fmt.Sprintf(rsrcForkNameTemplate, f.name)
+ return fmt.Sprintf(RsrcForkNameTemplate, f.Name)
}
func (f *fileWrapper) infoForkName() string {
- return fmt.Sprintf(infoForkNameTemplate, f.name)
+ return fmt.Sprintf(InfoForkNameTemplate, f.Name)
}
func (f *fileWrapper) rsrcForkWriter() (io.WriteCloser, error) {
return file, nil
}
-func (f *fileWrapper) infoForkWriter() (io.WriteCloser, error) {
+func (f *fileWrapper) InfoForkWriter() (io.WriteCloser, error) {
file, err := os.OpenFile(f.infoPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return nil, err
return f.fs.Open(f.rsrcPath)
}
-func (f *fileWrapper) dataFile() (os.FileInfo, error) {
+func (f *fileWrapper) DataFile() (os.FileInfo, error) {
if fi, err := f.fs.Stat(f.dataPath); err == nil {
return fi, nil
}
return nil, errors.New("file or directory not found")
}
-// move a fileWrapper and its associated meta files to newPath.
+// Move a file and its associated meta files to newPath.
// Meta files include:
// * Partially uploaded file ending with .incomplete
// * Resource fork starting with .rsrc_
// * Info fork starting with .info
-// During move of the meta files, os.ErrNotExist is ignored as these files may legitimately not exist.
-func (f *fileWrapper) move(newPath string) error {
- err := f.fs.Rename(f.dataPath, filepath.Join(newPath, f.name))
+// During Move of the meta files, os.ErrNotExist is ignored as these files may legitimately not exist.
+func (f *fileWrapper) Move(newPath string) error {
+ err := f.fs.Rename(f.dataPath, filepath.Join(newPath, f.Name))
if err != nil {
return err
}
return nil
}
-// delete a fileWrapper and its associated metadata files if they exist
-func (f *fileWrapper) delete() error {
+// Delete a fileWrapper and its associated metadata files if they exist
+func (f *fileWrapper) Delete() error {
err := f.fs.RemoveAll(f.dataPath)
if err != nil {
return err
if errors.Is(err, fs.ErrNotExist) {
fileInfo, err = f.fs.Stat(f.incompletePath)
if err == nil {
- mTime = toHotlineTime(fileInfo.ModTime())
+ mTime = NewTime(fileInfo.ModTime())
binary.BigEndian.PutUint32(dataSize, uint32(fileInfo.Size()-f.dataOffset))
ft, _ = fileTypeFromInfo(fileInfo)
}
} else {
- mTime = toHotlineTime(fileInfo.ModTime())
+ mTime = NewTime(fileInfo.ModTime())
binary.BigEndian.PutUint32(dataSize, uint32(fileInfo.Size()-f.dataOffset))
ft, _ = fileTypeFromInfo(fileInfo)
}
- f.ffo.FlatFileHeader = FlatFileHeader{
+ f.Ffo.FlatFileHeader = FlatFileHeader{
Format: [4]byte{0x46, 0x49, 0x4c, 0x50}, // "FILP"
Version: [2]byte{0, 1},
- RSVD: [16]byte{},
ForkCount: [2]byte{0, 2},
}
return nil, err
}
- f.ffo.FlatFileHeader.ForkCount[1] = 3
+ f.Ffo.FlatFileHeader.ForkCount[1] = 3
- _, err = io.Copy(&f.ffo.FlatFileInformationFork, bytes.NewReader(b))
+ _, err = io.Copy(&f.Ffo.FlatFileInformationFork, bytes.NewReader(b))
if err != nil {
return nil, fmt.Errorf("error copying FlatFileInformationFork: %w", err)
}
} else {
- f.ffo.FlatFileInformationFork = FlatFileInformationFork{
+ f.Ffo.FlatFileInformationFork = FlatFileInformationFork{
Platform: [4]byte{0x41, 0x4D, 0x41, 0x43}, // "AMAC" TODO: Remove hardcode to support "AWIN" Platform (maybe?)
TypeSignature: [4]byte([]byte(ft.TypeCode)),
CreatorSignature: [4]byte([]byte(ft.CreatorCode)),
PlatformFlags: [4]byte{0, 0, 1, 0}, // TODO: What is this?
CreateDate: mTime, // some filesystems don't support createTime
ModifyDate: mTime,
- Name: []byte(f.name),
+ Name: []byte(f.Name),
Comment: []byte{},
}
ns := make([]byte, 2)
- binary.BigEndian.PutUint16(ns, uint16(len(f.name)))
- f.ffo.FlatFileInformationFork.NameSize = [2]byte(ns[:])
+ binary.BigEndian.PutUint16(ns, uint16(len(f.Name)))
+ f.Ffo.FlatFileInformationFork.NameSize = [2]byte(ns[:])
}
- f.ffo.FlatFileInformationForkHeader = FlatFileForkHeader{
- ForkType: [4]byte{0x49, 0x4E, 0x46, 0x4F}, // "INFO"
- CompressionType: [4]byte{},
- RSVD: [4]byte{},
- DataSize: f.ffo.FlatFileInformationFork.Size(),
+ f.Ffo.FlatFileInformationForkHeader = FlatFileForkHeader{
+ ForkType: [4]byte{0x49, 0x4E, 0x46, 0x4F}, // "INFO"
+ DataSize: f.Ffo.FlatFileInformationFork.Size(),
}
- f.ffo.FlatFileDataForkHeader = FlatFileForkHeader{
- ForkType: [4]byte{0x44, 0x41, 0x54, 0x41}, // "DATA"
- CompressionType: [4]byte{},
- RSVD: [4]byte{},
- DataSize: [4]byte{dataSize[0], dataSize[1], dataSize[2], dataSize[3]},
+ f.Ffo.FlatFileDataForkHeader = FlatFileForkHeader{
+ ForkType: [4]byte{0x44, 0x41, 0x54, 0x41}, // "DATA"
+ DataSize: [4]byte{dataSize[0], dataSize[1], dataSize[2], dataSize[3]},
}
- f.ffo.FlatFileResForkHeader = f.rsrcForkHeader()
+ f.Ffo.FlatFileResForkHeader = f.rsrcForkHeader()
- return f.ffo, nil
+ return f.Ffo, nil
}
const maxFileSize = 4294967296
-func getFileNameList(path string, ignoreList []string) (fields []Field, err error) {
+func GetFileNameList(path string, ignoreList []string) (fields []Field, err error) {
files, err := os.ReadDir(path)
if err != nil {
return fields, fmt.Errorf("error reading path: %s: %w", path, err)
continue
}
- hlFile, err := newFileWrapper(&OSFileStore{}, path+"/"+file.Name(), 0)
+ hlFile, err := NewFileWrapper(&OSFileStore{}, path+"/"+file.Name(), 0)
if err != nil {
- return nil, fmt.Errorf("newFileWrapper: %w", err)
+ return nil, fmt.Errorf("NewFileWrapper: %w", err)
}
- copy(fnwi.FileSize[:], hlFile.totalSize())
- copy(fnwi.Type[:], hlFile.ffo.FlatFileInformationFork.TypeSignature[:])
- copy(fnwi.Creator[:], hlFile.ffo.FlatFileInformationFork.CreatorSignature[:])
+ copy(fnwi.FileSize[:], hlFile.TotalSize())
+ copy(fnwi.Type[:], hlFile.Ffo.FlatFileInformationFork.TypeSignature[:])
+ copy(fnwi.Creator[:], hlFile.Ffo.FlatFileInformationFork.CreatorSignature[:])
}
strippedName := strings.ReplaceAll(file.Name(), ".incomplete", "")
}
}
-func (ffif *FlatFileInformationFork) friendlyType() []byte {
+func (ffif *FlatFileInformationFork) FriendlyType() []byte {
if name, ok := friendlyCreatorNames[string(ffif.TypeSignature[:])]; ok {
return []byte(name)
}
return ffif.TypeSignature[:]
}
-func (ffif *FlatFileInformationFork) friendlyCreator() []byte {
+func (ffif *FlatFileInformationFork) FriendlyCreator() []byte {
if name, ok := friendlyCreatorNames[string(ffif.CreatorSignature[:])]; ok {
return []byte(name)
}
return ffif.CreatorSignature[:]
}
-func (ffif *FlatFileInformationFork) setComment(comment []byte) error {
+func (ffif *FlatFileInformationFork) SetComment(comment []byte) error {
commentSize := make([]byte, 2)
ffif.Comment = comment
binary.BigEndian.PutUint16(commentSize, uint16(len(comment)))
// Copy exactly handshakeSize bytes from rw to handshake
if _, err := io.CopyN(&h, rw, handshakeSize); err != nil {
- return fmt.Errorf("failed to read handshake data: %w", err)
+ return fmt.Errorf("read handshake: %w", err)
}
-
if !h.Valid() {
return errors.New("invalid protocol or sub-protocol in handshake")
}
if _, err := rw.Write(handshakeResponse[:]); err != nil {
- return fmt.Errorf("error sending handshake response: %w", err)
+ return fmt.Errorf("send handshake response: %w", err)
}
return nil
0x54, 0x52, 0x54, 0x50, // TRTP
},
expectedOutput: nil,
- expectedError: "failed to read handshake data: invalid handshake size",
+ expectedError: "read handshake: invalid handshake size",
},
{
name: "Invalid Protocol",
package hotline
-const defaultNewsDateFormat = "Jan02 15:04" // Jun23 20:49
+const NewsDateFormat = "Jan02 15:04" // Jun23 20:49
-const defaultNewsTemplate = `From %s (%s):
+const NewsTemplate = `From %s (%s):
%s
import (
"cmp"
"encoding/binary"
+ "github.com/stretchr/testify/mock"
"io"
"slices"
)
advance = 3 + int(data[2])
return advance, data[3:advance], nil
}
+
+type MockThreadNewsMgr struct {
+ mock.Mock
+}
+
+func (m *MockThreadNewsMgr) ListArticles(newsPath []string) NewsArtListData {
+ args := m.Called(newsPath)
+
+ return args.Get(0).(NewsArtListData)
+}
+
+func (m *MockThreadNewsMgr) GetArticle(newsPath []string, articleID uint32) *NewsArtData {
+ args := m.Called(newsPath, articleID)
+
+ return args.Get(0).(*NewsArtData)
+}
+func (m *MockThreadNewsMgr) DeleteArticle(newsPath []string, articleID uint32, recursive bool) error {
+ args := m.Called(newsPath, articleID, recursive)
+
+ return args.Error(0)
+}
+
+func (m *MockThreadNewsMgr) PostArticle(newsPath []string, parentArticleID uint32, article NewsArtData) error {
+ args := m.Called(newsPath, parentArticleID, article)
+
+ return args.Error(0)
+}
+func (m *MockThreadNewsMgr) CreateGrouping(newsPath []string, name string, itemType [2]byte) error {
+ args := m.Called(newsPath, name, itemType)
+
+ return args.Error(0)
+}
+
+func (m *MockThreadNewsMgr) GetCategories(paths []string) []NewsCategoryListData15 {
+ args := m.Called(paths)
+
+ return args.Get(0).([]NewsCategoryListData15)
+}
+
+func (m *MockThreadNewsMgr) NewsItem(newsPath []string) NewsCategoryListData15 {
+ args := m.Called(newsPath)
+
+ return args.Get(0).(NewsCategoryListData15)
+}
+
+func (m *MockThreadNewsMgr) DeleteNewsItem(newsPath []string) error {
+ args := m.Called(newsPath)
+
+ return args.Error(0)
+}
import (
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/mock"
"io"
"testing"
)
-type mockThreadNewsMgr struct {
- mock.Mock
-}
-
-func (m *mockThreadNewsMgr) ListArticles(newsPath []string) NewsArtListData {
- args := m.Called(newsPath)
-
- return args.Get(0).(NewsArtListData)
-}
-
-func (m *mockThreadNewsMgr) GetArticle(newsPath []string, articleID uint32) *NewsArtData {
- args := m.Called(newsPath, articleID)
-
- return args.Get(0).(*NewsArtData)
-}
-func (m *mockThreadNewsMgr) DeleteArticle(newsPath []string, articleID uint32, recursive bool) error {
- args := m.Called(newsPath, articleID, recursive)
-
- return args.Error(0)
-}
-
-func (m *mockThreadNewsMgr) PostArticle(newsPath []string, parentArticleID uint32, article NewsArtData) error {
- args := m.Called(newsPath, parentArticleID, article)
-
- return args.Error(0)
-}
-func (m *mockThreadNewsMgr) CreateGrouping(newsPath []string, name string, itemType [2]byte) error {
- args := m.Called(newsPath, name, itemType)
-
- return args.Error(0)
-}
-
-func (m *mockThreadNewsMgr) GetCategories(paths []string) []NewsCategoryListData15 {
- args := m.Called(paths)
-
- return args.Get(0).([]NewsCategoryListData15)
-}
-
-func (m *mockThreadNewsMgr) NewsItem(newsPath []string) NewsCategoryListData15 {
- args := m.Called(newsPath)
-
- return args.Get(0).(NewsCategoryListData15)
-}
-
-func (m *mockThreadNewsMgr) DeleteNewsItem(newsPath []string) error {
- args := m.Called(newsPath)
-
- return args.Error(0)
-}
-
func TestNewsCategoryListData15_MarshalBinary(t *testing.T) {
type fields struct {
Type [2]byte
"errors"
"fmt"
"golang.org/x/text/encoding/charmap"
- "gopkg.in/yaml.v3"
"io"
"log"
"log/slog"
"net"
- "os"
- "path/filepath"
"strings"
"sync"
"time"
NetInterface string
Port int
- Config Config
- ConfigDir string
- Logger *slog.Logger
+ handlers map[TranType]HandlerFunc
+
+ Config Config
+ Logger *slog.Logger
TrackerPassID [4]byte
outbox chan Transaction
- // TODO
- Agreement []byte
- banner []byte
- // END TODO
+ Agreement io.ReadSeeker
+ Banner []byte
FileTransferMgr FileTransferMgr
ChatMgr ChatManager
MessageBoard io.ReadWriteSeeker
}
-// NewServer constructs a new Server from a config dir
-func NewServer(config Config, configDir, netInterface string, netPort int, logger *slog.Logger, fs FileStore) (*Server, error) {
- server := Server{
- NetInterface: netInterface,
- Port: netPort,
- Config: config,
- ConfigDir: configDir,
- Logger: logger,
- outbox: make(chan Transaction),
- Stats: NewStats(),
- FS: fs,
- ChatMgr: NewMemChatManager(),
- ClientMgr: NewMemClientMgr(),
- FileTransferMgr: NewMemFileTransferMgr(),
- }
+type Option = func(s *Server)
- // generate a new random passID for tracker registration
- _, err := rand.Read(server.TrackerPassID[:])
- if err != nil {
- return nil, err
+func WithConfig(config Config) func(s *Server) {
+ return func(s *Server) {
+ s.Config = config
}
+}
- server.Agreement, err = os.ReadFile(filepath.Join(configDir, agreementFile))
- if err != nil {
- return nil, err
+func WithLogger(logger *slog.Logger) func(s *Server) {
+ return func(s *Server) {
+ s.Logger = logger
}
+}
- server.AccountManager, err = NewYAMLAccountManager(filepath.Join(configDir, "Users/"))
- if err != nil {
- return nil, fmt.Errorf("error loading accounts: %w", err)
+// WithPort optionally overrides the default TCP port.
+func WithPort(port int) func(s *Server) {
+ return func(s *Server) {
+ s.Port = port
}
+}
- // If the FileRoot is an absolute path, use it, otherwise treat as a relative path to the config dir.
- if !filepath.IsAbs(server.Config.FileRoot) {
- server.Config.FileRoot = filepath.Join(configDir, server.Config.FileRoot)
+// WithInterface optionally sets a specific interface to listen on.
+func WithInterface(netInterface string) func(s *Server) {
+ return func(s *Server) {
+ s.NetInterface = netInterface
}
+}
- server.banner, err = os.ReadFile(filepath.Join(server.ConfigDir, server.Config.BannerFile))
- if err != nil {
- return nil, fmt.Errorf("error opening banner: %w", err)
- }
+type ServerConfig struct {
+}
- if server.Config.EnableTrackerRegistration {
- server.Logger.Info(
- "Tracker registration enabled",
- "frequency", fmt.Sprintf("%vs", trackerUpdateFrequency),
- "trackers", server.Config.Trackers,
- )
+func NewServer(options ...Option) (*Server, error) {
+ server := Server{
+ handlers: make(map[TranType]HandlerFunc),
+ outbox: make(chan Transaction),
+ FS: &OSFileStore{},
+ ChatMgr: NewMemChatManager(),
+ ClientMgr: NewMemClientMgr(),
+ FileTransferMgr: NewMemFileTransferMgr(),
+ Stats: NewStats(),
+ }
- go server.registerWithTrackers()
+ for _, opt := range options {
+ opt(&server)
}
- // Start Client Keepalive go routine
- go server.keepaliveHandler()
+ // generate a new random passID for tracker registration
+ _, err := rand.Read(server.TrackerPassID[:])
+ if err != nil {
+ return nil, err
+ }
return &server, nil
}
}
func (s *Server) ListenAndServe(ctx context.Context) error {
+ go s.registerWithTrackers(ctx)
+ go s.keepaliveHandler(ctx)
+ go s.processOutbox()
+
var wg sync.WaitGroup
wg.Add(1)
}
func (s *Server) sendTransaction(t Transaction) error {
- client := s.ClientMgr.Get(t.clientID)
+ client := s.ClientMgr.Get(t.ClientID)
if client == nil {
return nil
_, err := io.Copy(client.Connection, &t)
if err != nil {
- return fmt.Errorf("failed to send transaction to client %v: %v", t.clientID, err)
+ return fmt.Errorf("failed to send transaction to client %v: %v", t.ClientID, err)
}
return nil
}
func (s *Server) Serve(ctx context.Context, ln net.Listener) error {
- go s.processOutbox()
-
for {
- conn, err := ln.Accept()
- if err != nil {
- s.Logger.Error("error accepting connection", "err", err)
- }
- connCtx := context.WithValue(ctx, contextKeyReq, requestCtx{
- remoteAddr: conn.RemoteAddr().String(),
- })
+ select {
+ case <-ctx.Done():
+ s.Logger.Info("Server shutting down")
+ return ctx.Err()
+ default:
+ conn, err := ln.Accept()
+ if err != nil {
+ s.Logger.Error("Error accepting connection", "err", err)
+ continue
+ }
- go func() {
- s.Logger.Info("Connection established", "RemoteAddr", conn.RemoteAddr())
-
- defer conn.Close()
- if err := s.handleNewConnection(connCtx, conn, conn.RemoteAddr().String()); err != nil {
- if err == io.EOF {
- s.Logger.Info("Client disconnected", "RemoteAddr", conn.RemoteAddr())
- } else {
- s.Logger.Error("error serving request", "RemoteAddr", conn.RemoteAddr(), "err", err)
+ go func() {
+ connCtx := context.WithValue(ctx, contextKeyReq, requestCtx{
+ remoteAddr: conn.RemoteAddr().String(),
+ })
+
+ s.Logger.Info("Connection established", "addr", conn.RemoteAddr())
+ defer conn.Close()
+
+ if err := s.handleNewConnection(connCtx, conn, conn.RemoteAddr().String()); err != nil {
+ if err == io.EOF {
+ s.Logger.Info("Client disconnected", "RemoteAddr", conn.RemoteAddr())
+ } else {
+ s.Logger.Error("Error serving request", "RemoteAddr", conn.RemoteAddr(), "err", err)
+ }
}
- }
- }()
+ }()
+ }
}
}
-const (
- agreementFile = "Agreement.txt"
-)
+// time in seconds between tracker re-registration
+const trackerUpdateFrequency = 300
+
+// registerWithTrackers runs every trackerUpdateFrequency seconds to update the server's tracker entry on all configured
+// trackers.
+func (s *Server) registerWithTrackers(ctx context.Context) {
+ ticker := time.NewTicker(trackerUpdateFrequency * time.Second)
+ defer ticker.Stop()
-func (s *Server) registerWithTrackers() {
for {
- tr := &TrackerRegistration{
- UserCount: len(s.ClientMgr.List()),
- PassID: s.TrackerPassID,
- Name: s.Config.Name,
- Description: s.Config.Description,
- }
- binary.BigEndian.PutUint16(tr.Port[:], uint16(s.Port))
- for _, t := range s.Config.Trackers {
- if err := register(&RealDialer{}, t, tr); err != nil {
- s.Logger.Error(fmt.Sprintf("unable to register with tracker %v", t), "error", err)
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ if s.Config.EnableTrackerRegistration {
+ tr := &TrackerRegistration{
+ UserCount: len(s.ClientMgr.List()),
+ PassID: s.TrackerPassID,
+ Name: s.Config.Name,
+ Description: s.Config.Description,
+ }
+ binary.BigEndian.PutUint16(tr.Port[:], uint16(s.Port))
+
+ for _, t := range s.Config.Trackers {
+ if err := register(&RealDialer{}, t, tr); err != nil {
+ s.Logger.Error(fmt.Sprintf("Unable to register with tracker %v", t), "error", err)
+ }
+ }
}
}
-
- time.Sleep(trackerUpdateFrequency * time.Second)
}
-}
-// keepaliveHandler
-func (s *Server) keepaliveHandler() {
- for {
- time.Sleep(idleCheckInterval * time.Second)
-
- for _, c := range s.ClientMgr.List() {
- c.mu.Lock()
+}
- c.IdleTime += idleCheckInterval
+const (
+ userIdleSeconds = 300 // time in seconds before an inactive user is marked idle
+ idleCheckInterval = 10 // time in seconds to check for idle users
+)
- // Check if the user
- if c.IdleTime > userIdleSeconds && !c.Flags.IsSet(UserFlagAway) {
- c.Flags.Set(UserFlagAway, 1)
+// keepaliveHandler runs every idleCheckInterval seconds and increments a user's idle time by idleCheckInterval seconds.
+// If the updated idle time exceeds userIdleSeconds and the user was not previously idle, we notify all connected clients
+// that the user has gone idle. For most clients, this turns the user grey in the user list.
+func (s *Server) keepaliveHandler(ctx context.Context) {
+ ticker := time.NewTicker(idleCheckInterval * time.Second)
+ defer ticker.Stop()
- c.SendAll(
- TranNotifyChangeUser,
- NewField(FieldUserID, c.ID[:]),
- NewField(FieldUserFlags, c.Flags[:]),
- NewField(FieldUserName, c.UserName),
- NewField(FieldUserIconID, c.Icon),
- )
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ for _, c := range s.ClientMgr.List() {
+ c.mu.Lock()
+ c.IdleTime += idleCheckInterval
+
+ // Check if the user
+ if c.IdleTime > userIdleSeconds && !c.Flags.IsSet(UserFlagAway) {
+ c.Flags.Set(UserFlagAway, 1)
+
+ c.SendAll(
+ TranNotifyChangeUser,
+ NewField(FieldUserID, c.ID[:]),
+ NewField(FieldUserFlags, c.Flags[:]),
+ NewField(FieldUserName, c.UserName),
+ NewField(FieldUserIconID, c.Icon),
+ )
+ }
+ c.mu.Unlock()
}
- c.mu.Unlock()
}
}
}
return clientConn
}
-// loadFromYAMLFile loads data from a YAML file into the provided data structure.
-func loadFromYAMLFile(path string, data interface{}) error {
- fh, err := os.Open(path)
- if err != nil {
- return err
- }
- defer fh.Close()
-
- decoder := yaml.NewDecoder(fh)
- return decoder.Decode(data)
-}
-
func sendBanMessage(rwc io.Writer, message string) {
t := NewTransaction(
TranServerMsg,
func (s *Server) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser, remoteAddr string) error {
defer dontPanic(s.Logger)
+ if err := performHandshake(rwc); err != nil {
+ return fmt.Errorf("perform handshake: %w", err)
+ }
+
// Check if remoteAddr is present in the ban list
ipAddr := strings.Split(remoteAddr, ":")[0]
if isBanned, banUntil := s.BanList.IsBanned(ipAddr); isBanned {
}
}
- if err := performHandshake(rwc); err != nil {
- return fmt.Errorf("error performing handshake: %w", err)
- }
-
// Create a new scanner for parsing incoming bytes into transaction tokens
scanner := bufio.NewScanner(rwc)
scanner.Split(transactionScanner)
login = GuestAccount
}
- c.logger = s.Logger.With("ip", ipAddr, "login", login)
+ c.Logger = s.Logger.With("ip", ipAddr, "login", login)
// If authentication fails, send error reply and close connection
if !c.Authenticate(login, encodedPassword) {
return err
}
- c.logger.Info("Login failed", "clientVersion", fmt.Sprintf("%x", c.Version))
+ c.Logger.Info("Login failed", "clientVersion", fmt.Sprintf("%x", c.Version))
return nil
}
c.Server.outbox <- NewTransaction(TranShowAgreement, c.ID, NewField(FieldNoServerAgreement, []byte{1}))
}
} else {
- c.Server.outbox <- NewTransaction(TranShowAgreement, c.ID, NewField(FieldData, s.Agreement))
+ _, _ = c.Server.Agreement.Seek(0, 0)
+ data, _ := io.ReadAll(c.Server.Agreement)
+
+ c.Server.outbox <- NewTransaction(TranShowAgreement, c.ID, NewField(FieldData, data))
}
// If the client has provided a username as part of the login, we can infer that it is using the 1.2.3 login
if len(c.UserName) != 0 {
// Add the client username to the logger. For 1.5+ clients, we don't have this information yet as it comes as
// part of TranAgreed
- c.logger = c.logger.With("Name", string(c.UserName))
- c.logger.Info("Login successful", "clientVersion", "Not sent (probably 1.2.3)")
+ c.Logger = c.Logger.With("name", string(c.UserName))
+ c.Logger.Info("Login successful")
// Notify other clients on the server that the new user has logged in. For 1.5+ clients we don't have this
// information yet, so we do it in TranAgreed instead
// Scan for new transactions and handle them as they come in.
for scanner.Scan() {
// Copy the scanner bytes to a new slice to it to avoid a data race when the scanner re-uses the buffer.
- buf := make([]byte, len(scanner.Bytes()))
- copy(buf, scanner.Bytes())
+ tmpBuf := make([]byte, len(scanner.Bytes()))
+ copy(tmpBuf, scanner.Bytes())
var t Transaction
- if _, err := t.Write(buf); err != nil {
+ if _, err := t.Write(tmpBuf); err != nil {
return err
}
"Name", string(fileTransfer.ClientConn.UserName),
)
- fullPath, err := readPath(s.Config.FileRoot, fileTransfer.FilePath, fileTransfer.FileName)
+ fullPath, err := ReadPath(s.Config.FileRoot, fileTransfer.FilePath, fileTransfer.FileName)
if err != nil {
return err
}
switch fileTransfer.Type {
case BannerDownload:
- if _, err := io.Copy(rwc, bytes.NewBuffer(s.banner)); err != nil {
- return fmt.Errorf("error sending banner: %w", err)
+ if _, err := io.Copy(rwc, bytes.NewBuffer(s.Banner)); err != nil {
+ return fmt.Errorf("error sending Banner: %w", err)
}
case FileDownload:
s.Stats.Increment(StatDownloadCounter, StatDownloadsInProgress)
var tranSortFunc = func(a, b Transaction) int {
return cmp.Compare(
- binary.BigEndian.Uint16(a.clientID[:]),
- binary.BigEndian.Uint16(b.clientID[:]),
+ binary.BigEndian.Uint16(a.ClientID[:]),
+ binary.BigEndian.Uint16(b.ClientID[:]),
)
}
-// tranAssertEqual compares equality of transactions slices after stripping out the random transaction Type
-func tranAssertEqual(t *testing.T, tran1, tran2 []Transaction) bool {
+// TranAssertEqual compares equality of transactions slices after stripping out the random transaction Type
+func TranAssertEqual(t *testing.T, tran1, tran2 []Transaction) bool {
var newT1 []Transaction
var newT2 []Transaction
FileTransferMgr: &MemFileTransferMgr{
fileTransfers: map[FileTransferID]*FileTransfer{
{0, 0, 0, 5}: {
- refNum: [4]byte{0, 0, 0, 5},
+ RefNum: [4]byte{0, 0, 0, 5},
Type: FileDownload,
FileName: []byte("testfile-8b"),
FilePath: []byte{},
s := &Server{
FileTransferMgr: tt.fields.FileTransferMgr,
Config: tt.fields.Config,
- ConfigDir: tt.fields.ConfigDir,
Logger: tt.fields.Logger,
Stats: tt.fields.Stats,
FS: tt.fields.FS,
})
}
}
-
-type TestData struct {
- Name string `yaml:"name"`
- Value int `yaml:"value"`
-}
-
-func TestLoadFromYAMLFile(t *testing.T) {
- tests := []struct {
- name string
- fileName string
- content string
- wantData TestData
- wantErr bool
- }{
- {
- name: "Valid YAML file",
- fileName: "valid.yaml",
- content: "name: Test\nvalue: 123\n",
- wantData: TestData{Name: "Test", Value: 123},
- wantErr: false,
- },
- {
- name: "File not found",
- fileName: "nonexistent.yaml",
- content: "",
- wantData: TestData{},
- wantErr: true,
- },
- {
- name: "Invalid YAML content",
- fileName: "invalid.yaml",
- content: "name: Test\nvalue: invalid_int\n",
- wantData: TestData{},
- wantErr: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Setup: Create a temporary file with the provided content if content is not empty
- if tt.content != "" {
- err := os.WriteFile(tt.fileName, []byte(tt.content), 0644)
- assert.NoError(t, err)
- defer os.Remove(tt.fileName) // Cleanup the file after the test
- }
-
- var data TestData
- err := loadFromYAMLFile(tt.fileName, &data)
-
- if tt.wantErr {
- assert.Error(t, err)
- } else {
- assert.NoError(t, err)
- assert.Equal(t, tt.wantData, data)
- }
- })
- }
-}
Name: Halcyon's Test Server
Description: Experimental Hotline server
-BannerID: 1
FileRoot: conFiles/
EnableTrackerRegistration: false
Trackers:
"time"
)
-// toHotlineTime converts a time.Time to the 8 byte Hotline time format:
+type Time [8]byte
+
+// NewTime converts a time.Time to the 8 byte Hotline time format:
// Year (2 bytes), milliseconds (2 bytes) and seconds (4 bytes)
-func toHotlineTime(t time.Time) (b [8]byte) {
+func NewTime(t time.Time) (b Time) {
yearBytes := make([]byte, 2)
secondBytes := make([]byte, 4)
ParamCount [2]byte // Number of the parameters for this transaction
Fields []Field
- clientID [2]byte // Internal identifier for target client
- readOffset int // Internal offset to track read progress
+ ClientID ClientID // Internal identifier for target client
+ readOffset int // Internal offset to track read progress
}
var tranTypeNames = map[TranType]string{
TranChatMsg: "Receive chat",
TranNotifyChangeUser: "User change",
TranError: "Error",
- TranShowAgreement: "Show Agreement",
+ TranShowAgreement: "Show agreement",
TranUserAccess: "User access",
TranNotifyDeleteUser: "User left",
- TranAgreed: "TranAgreed",
+ TranAgreed: "Accept agreement",
TranChatSend: "Send chat",
TranDelNewsArt: "Delete news article",
TranDelNewsItem: "Delete news item",
TranDownloadBanner: "Download banner",
}
-//func (t TranType) LogValue() slog.Value {
-// return slog.StringValue(tranTypeNames[t])
-//}
-
-// NewTransaction creates a new Transaction with the specified type, client Type, and optional fields.
-func NewTransaction(t TranType, clientID [2]byte, fields ...Field) Transaction {
+// NewTransaction creates a new Transaction with the specified type, client, and optional fields.
+func NewTransaction(t TranType, clientID ClientID, fields ...Field) Transaction {
transaction := Transaction{
Type: t,
- clientID: clientID,
+ ClientID: clientID,
Fields: fields,
}
+ // Give the transaction a random ID.
binary.BigEndian.PutUint32(transaction.ID[:], rand.Uint32())
return transaction
copy(t.ParamCount[:], p[20:22])
scanner := bufio.NewScanner(bytes.NewReader(p[22:tranLen]))
- scanner.Split(fieldScanner)
+ scanner.Split(FieldScanner)
for i := 0; i < int(paramCount); i++ {
if !scanner.Scan() {
package hotline
-import (
- "bufio"
- "bytes"
- "encoding/binary"
- "fmt"
- "io"
- "math/big"
- "os"
- "path"
- "path/filepath"
- "strings"
- "time"
-)
-
// HandlerFunc is the signature of a func to handle a Hotline transaction.
type HandlerFunc func(*ClientConn, *Transaction) []Transaction
-// TransactionHandlers maps a transaction type to a handler function.
-var TransactionHandlers = map[TranType]HandlerFunc{
- TranAgreed: HandleTranAgreed,
- TranChatSend: HandleChatSend,
- TranDelNewsArt: HandleDelNewsArt,
- TranDelNewsItem: HandleDelNewsItem,
- TranDeleteFile: HandleDeleteFile,
- TranDeleteUser: HandleDeleteUser,
- TranDisconnectUser: HandleDisconnectUser,
- TranDownloadFile: HandleDownloadFile,
- TranDownloadFldr: HandleDownloadFolder,
- TranGetClientInfoText: HandleGetClientInfoText,
- TranGetFileInfo: HandleGetFileInfo,
- TranGetFileNameList: HandleGetFileNameList,
- TranGetMsgs: HandleGetMsgs,
- TranGetNewsArtData: HandleGetNewsArtData,
- TranGetNewsArtNameList: HandleGetNewsArtNameList,
- TranGetNewsCatNameList: HandleGetNewsCatNameList,
- TranGetUser: HandleGetUser,
- TranGetUserNameList: HandleGetUserNameList,
- TranInviteNewChat: HandleInviteNewChat,
- TranInviteToChat: HandleInviteToChat,
- TranJoinChat: HandleJoinChat,
- TranKeepAlive: HandleKeepAlive,
- TranLeaveChat: HandleLeaveChat,
- TranListUsers: HandleListUsers,
- TranMoveFile: HandleMoveFile,
- TranNewFolder: HandleNewFolder,
- TranNewNewsCat: HandleNewNewsCat,
- TranNewNewsFldr: HandleNewNewsFldr,
- TranNewUser: HandleNewUser,
- TranUpdateUser: HandleUpdateUser,
- TranOldPostNews: HandleTranOldPostNews,
- TranPostNewsArt: HandlePostNewsArt,
- TranRejectChatInvite: HandleRejectChatInvite,
- TranSendInstantMsg: HandleSendInstantMsg,
- TranSetChatSubject: HandleSetChatSubject,
- TranMakeFileAlias: HandleMakeAlias,
- TranSetClientUserInfo: HandleSetClientUserInfo,
- TranSetFileInfo: HandleSetFileInfo,
- TranSetUser: HandleSetUser,
- TranUploadFile: HandleUploadFile,
- TranUploadFldr: HandleUploadFolder,
- TranUserBroadcast: HandleUserBroadcast,
- TranDownloadBanner: HandleDownloadBanner,
+func (s *Server) HandleFunc(tranType [2]byte, handler HandlerFunc) {
+ s.handlers[tranType] = handler
}
// The total size of a chat message data field is 8192 bytes.
-const chatMsgLimit = 8192
-
-func HandleChatSend(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessSendChat) {
- return cc.NewErrReply(t, "You are not allowed to participate in chat.")
- }
-
- // Truncate long usernames
- // %13.13s: This means a string that is right-aligned in a field of 13 characters.
- // If the string is longer than 13 characters, it will be truncated to 13 characters.
- formattedMsg := fmt.Sprintf("\r%13.13s: %s", cc.UserName, t.GetField(FieldData).Data)
-
- // By holding the option key, Hotline chat allows users to send /me formatted messages like:
- // *** Halcyon does stuff
- // This is indicated by the presence of the optional field FieldChatOptions set to a value of 1.
- // Most clients do not send this option for normal chat messages.
- if t.GetField(FieldChatOptions).Data != nil && bytes.Equal(t.GetField(FieldChatOptions).Data, []byte{0, 1}) {
- formattedMsg = fmt.Sprintf("\r*** %s %s", cc.UserName, t.GetField(FieldData).Data)
- }
-
- // Truncate the message to the limit. This does not handle the edge case of a string ending on multibyte character.
- formattedMsg = formattedMsg[:min(len(formattedMsg), chatMsgLimit)]
-
- // The ChatID field is used to identify messages as belonging to a private chat.
- // All clients *except* Frogblast omit this field for public chat, but Frogblast sends a value of 00 00 00 00.
- chatID := t.GetField(FieldChatID).Data
- if chatID != nil && !bytes.Equal([]byte{0, 0, 0, 0}, chatID) {
-
- // send the message to all connected clients of the private chat
- for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) {
- res = append(res, NewTransaction(
- TranChatMsg,
- c.ID,
- NewField(FieldChatID, chatID),
- NewField(FieldData, []byte(formattedMsg)),
- ))
- }
- return res
- }
-
- //cc.Server.mux.Lock()
- for _, c := range cc.Server.ClientMgr.List() {
- if c == nil || cc.Account == nil {
- continue
- }
- // Skip clients that do not have the read chat permission.
- if c.Authorize(AccessReadChat) {
- res = append(res, NewTransaction(TranChatMsg, c.ID, NewField(FieldData, []byte(formattedMsg))))
- }
- }
- //cc.Server.mux.Unlock()
-
- return res
-}
-
-// HandleSendInstantMsg sends instant message to the user on the current server.
-// Fields used in the request:
-//
-// 103 User Type
-// 113 Options
-// One of the following values:
-// - User message (myOpt_UserMessage = 1)
-// - Refuse message (myOpt_RefuseMessage = 2)
-// - Refuse chat (myOpt_RefuseChat = 3)
-// - Automatic response (myOpt_AutomaticResponse = 4)"
-// 101 Data Optional
-// 214 Quoting message Optional
-//
-// Fields used in the reply:
-// None
-func HandleSendInstantMsg(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessSendPrivMsg) {
- return cc.NewErrReply(t, "You are not allowed to send private messages.")
- }
-
- msg := t.GetField(FieldData)
- userID := t.GetField(FieldUserID)
-
- reply := NewTransaction(
- TranServerMsg,
- [2]byte(userID.Data),
- NewField(FieldData, msg.Data),
- NewField(FieldUserName, cc.UserName),
- NewField(FieldUserID, cc.ID[:]),
- NewField(FieldOptions, []byte{0, 1}),
- )
-
- // Later versions of Hotline include the original message in the FieldQuotingMsg field so
- // the receiving client can display both the received message and what it is in reply to
- if t.GetField(FieldQuotingMsg).Data != nil {
- reply.Fields = append(reply.Fields, NewField(FieldQuotingMsg, t.GetField(FieldQuotingMsg).Data))
- }
-
- otherClient := cc.Server.ClientMgr.Get([2]byte(userID.Data))
- if otherClient == nil {
- return res
- }
-
- // Check if target user has "Refuse private messages" flag
- if otherClient.Flags.IsSet(UserFlagRefusePM) {
- res = append(res,
- NewTransaction(
- TranServerMsg,
- cc.ID,
- NewField(FieldData, []byte(string(otherClient.UserName)+" does not accept private messages.")),
- NewField(FieldUserName, otherClient.UserName),
- NewField(FieldUserID, otherClient.ID[:]),
- NewField(FieldOptions, []byte{0, 2}),
- ),
- )
- } else {
- res = append(res, reply)
- }
-
- // Respond with auto reply if other client has it enabled
- if len(otherClient.AutoReply) > 0 {
- res = append(res,
- NewTransaction(
- TranServerMsg,
- cc.ID,
- NewField(FieldData, otherClient.AutoReply),
- NewField(FieldUserName, otherClient.UserName),
- NewField(FieldUserID, otherClient.ID[:]),
- NewField(FieldOptions, []byte{0, 1}),
- ),
- )
- }
-
- return append(res, cc.NewReply(t))
-}
-
-var fileTypeFLDR = [4]byte{0x66, 0x6c, 0x64, 0x72}
-
-func HandleGetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction) {
- fileName := t.GetField(FieldFileName).Data
- filePath := t.GetField(FieldFilePath).Data
-
- fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
- if err != nil {
- return res
- }
-
- fw, err := newFileWrapper(cc.Server.FS, fullFilePath, 0)
- if err != nil {
- return res
- }
-
- encodedName, err := txtEncoder.String(fw.name)
- if err != nil {
- return res
- }
-
- fields := []Field{
- NewField(FieldFileName, []byte(encodedName)),
- NewField(FieldFileTypeString, fw.ffo.FlatFileInformationFork.friendlyType()),
- NewField(FieldFileCreatorString, fw.ffo.FlatFileInformationFork.friendlyCreator()),
- NewField(FieldFileType, fw.ffo.FlatFileInformationFork.TypeSignature[:]),
- NewField(FieldFileCreateDate, fw.ffo.FlatFileInformationFork.CreateDate[:]),
- NewField(FieldFileModifyDate, fw.ffo.FlatFileInformationFork.ModifyDate[:]),
- }
-
- // Include the optional FileComment field if there is a comment.
- if len(fw.ffo.FlatFileInformationFork.Comment) != 0 {
- fields = append(fields, NewField(FieldFileComment, fw.ffo.FlatFileInformationFork.Comment))
- }
-
- // Include the FileSize field for files.
- if fw.ffo.FlatFileInformationFork.TypeSignature != fileTypeFLDR {
- fields = append(fields, NewField(FieldFileSize, fw.totalSize()))
- }
-
- res = append(res, cc.NewReply(t, fields...))
- return res
-}
-
-// HandleSetFileInfo updates a file or folder Name and/or comment from the Get Info window
-// Fields used in the request:
-// * 201 File Name
-// * 202 File path Optional
-// * 211 File new Name Optional
-// * 210 File comment Optional
-// Fields used in the reply: None
-func HandleSetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction) {
- fileName := t.GetField(FieldFileName).Data
- filePath := t.GetField(FieldFilePath).Data
-
- fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
- if err != nil {
- return res
- }
-
- fi, err := cc.Server.FS.Stat(fullFilePath)
- if err != nil {
- return res
- }
-
- hlFile, err := newFileWrapper(cc.Server.FS, fullFilePath, 0)
- if err != nil {
- return res
- }
- if t.GetField(FieldFileComment).Data != nil {
- switch mode := fi.Mode(); {
- case mode.IsDir():
- if !cc.Authorize(AccessSetFolderComment) {
- return cc.NewErrReply(t, "You are not allowed to set comments for folders.")
- }
- case mode.IsRegular():
- if !cc.Authorize(AccessSetFileComment) {
- return cc.NewErrReply(t, "You are not allowed to set comments for files.")
- }
- }
-
- if err := hlFile.ffo.FlatFileInformationFork.setComment(t.GetField(FieldFileComment).Data); err != nil {
- return res
- }
- w, err := hlFile.infoForkWriter()
- if err != nil {
- return res
- }
- _, err = io.Copy(w, &hlFile.ffo.FlatFileInformationFork)
- if err != nil {
- return res
- }
- }
-
- fullNewFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, t.GetField(FieldFileNewName).Data)
- if err != nil {
- return nil
- }
-
- fileNewName := t.GetField(FieldFileNewName).Data
-
- if fileNewName != nil {
- switch mode := fi.Mode(); {
- case mode.IsDir():
- if !cc.Authorize(AccessRenameFolder) {
- return cc.NewErrReply(t, "You are not allowed to rename folders.")
- }
- err = os.Rename(fullFilePath, fullNewFilePath)
- if os.IsNotExist(err) {
- return cc.NewErrReply(t, "Cannot rename folder "+string(fileName)+" because it does not exist or cannot be found.")
-
- }
- case mode.IsRegular():
- if !cc.Authorize(AccessRenameFile) {
- return cc.NewErrReply(t, "You are not allowed to rename files.")
- }
- fileDir, err := readPath(cc.Server.Config.FileRoot, filePath, []byte{})
- if err != nil {
- return nil
- }
- hlFile.name, err = txtDecoder.String(string(fileNewName))
- if err != nil {
- return res
- }
-
- err = hlFile.move(fileDir)
- if os.IsNotExist(err) {
- return cc.NewErrReply(t, "Cannot rename file "+string(fileName)+" because it does not exist or cannot be found.")
- }
- if err != nil {
- return res
- }
- }
- }
-
- res = append(res, cc.NewReply(t))
- return res
-}
-
-// HandleDeleteFile deletes a file or folder
-// Fields used in the request:
-// * 201 File Name
-// * 202 File path
-// Fields used in the reply: none
-func HandleDeleteFile(cc *ClientConn, t *Transaction) (res []Transaction) {
- fileName := t.GetField(FieldFileName).Data
- filePath := t.GetField(FieldFilePath).Data
-
- fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
- if err != nil {
- return res
- }
-
- hlFile, err := newFileWrapper(cc.Server.FS, fullFilePath, 0)
- if err != nil {
- return res
- }
-
- fi, err := hlFile.dataFile()
- if err != nil {
- return cc.NewErrReply(t, "Cannot delete file "+string(fileName)+" because it does not exist or cannot be found.")
- }
-
- switch mode := fi.Mode(); {
- case mode.IsDir():
- if !cc.Authorize(AccessDeleteFolder) {
- return cc.NewErrReply(t, "You are not allowed to delete folders.")
- }
- case mode.IsRegular():
- if !cc.Authorize(AccessDeleteFile) {
- return cc.NewErrReply(t, "You are not allowed to delete files.")
- }
- }
-
- if err := hlFile.delete(); err != nil {
- return res
- }
-
- res = append(res, cc.NewReply(t))
- return res
-}
-
-// HandleMoveFile moves files or folders. Note: seemingly not documented
-func HandleMoveFile(cc *ClientConn, t *Transaction) (res []Transaction) {
- fileName := string(t.GetField(FieldFileName).Data)
-
- filePath, err := readPath(cc.Server.Config.FileRoot, t.GetField(FieldFilePath).Data, t.GetField(FieldFileName).Data)
- if err != nil {
- return res
- }
-
- fileNewPath, err := readPath(cc.Server.Config.FileRoot, t.GetField(FieldFileNewPath).Data, nil)
- if err != nil {
- return res
- }
-
- cc.logger.Info("Move file", "src", filePath+"/"+fileName, "dst", fileNewPath+"/"+fileName)
-
- hlFile, err := newFileWrapper(cc.Server.FS, filePath, 0)
- if err != nil {
- return res
- }
-
- fi, err := hlFile.dataFile()
- if err != nil {
- return cc.NewErrReply(t, "Cannot delete file "+fileName+" because it does not exist or cannot be found.")
- }
- switch mode := fi.Mode(); {
- case mode.IsDir():
- if !cc.Authorize(AccessMoveFolder) {
- return cc.NewErrReply(t, "You are not allowed to move folders.")
- }
- case mode.IsRegular():
- if !cc.Authorize(AccessMoveFile) {
- return cc.NewErrReply(t, "You are not allowed to move files.")
- }
- }
- if err := hlFile.move(fileNewPath); err != nil {
- return res
- }
- // TODO: handle other possible errors; e.g. fileWrapper delete fails due to fileWrapper permission issue
-
- res = append(res, cc.NewReply(t))
- return res
-}
-
-func HandleNewFolder(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessCreateFolder) {
- return cc.NewErrReply(t, "You are not allowed to create folders.")
- }
- folderName := string(t.GetField(FieldFileName).Data)
-
- folderName = path.Join("/", folderName)
-
- var subPath string
-
- // FieldFilePath is only present for nested paths
- if t.GetField(FieldFilePath).Data != nil {
- var newFp FilePath
- _, err := newFp.Write(t.GetField(FieldFilePath).Data)
- if err != nil {
- return res
- }
-
- for _, pathItem := range newFp.Items {
- subPath = filepath.Join("/", subPath, string(pathItem.Name))
- }
- }
- newFolderPath := path.Join(cc.Server.Config.FileRoot, subPath, folderName)
- newFolderPath, err := txtDecoder.String(newFolderPath)
- if err != nil {
- return res
- }
-
- // TODO: check path and folder Name lengths
-
- if _, err := cc.Server.FS.Stat(newFolderPath); !os.IsNotExist(err) {
- msg := fmt.Sprintf("Cannot create folder \"%s\" because there is already a file or folder with that Name.", folderName)
- return cc.NewErrReply(t, msg)
- }
-
- if err := cc.Server.FS.Mkdir(newFolderPath, 0777); err != nil {
- msg := fmt.Sprintf("Cannot create folder \"%s\" because an error occurred.", folderName)
- return cc.NewErrReply(t, msg)
- }
-
- return append(res, cc.NewReply(t))
-}
-
-func HandleSetUser(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessModifyUser) {
- return cc.NewErrReply(t, "You are not allowed to modify accounts.")
- }
-
- login := t.GetField(FieldUserLogin).DecodeObfuscatedString()
- userName := string(t.GetField(FieldUserName).Data)
-
- newAccessLvl := t.GetField(FieldUserAccess).Data
-
- account := cc.Server.AccountManager.Get(login)
- if account == nil {
- return cc.NewErrReply(t, "Account not found.")
- }
- account.Name = userName
- copy(account.Access[:], newAccessLvl)
-
- // If the password field is cleared in the Hotline edit user UI, the SetUser transaction does
- // not include FieldUserPassword
- if t.GetField(FieldUserPassword).Data == nil {
- account.Password = hashAndSalt([]byte(""))
- }
-
- if !bytes.Equal([]byte{0}, t.GetField(FieldUserPassword).Data) {
- account.Password = hashAndSalt(t.GetField(FieldUserPassword).Data)
- }
-
- err := cc.Server.AccountManager.Update(*account, account.Login)
- if err != nil {
- cc.logger.Error("Error updating account", "Err", err)
- }
-
- // Notify connected clients logged in as the user of the new access level
- for _, c := range cc.Server.ClientMgr.List() {
- if c.Account.Login == login {
- newT := NewTransaction(TranUserAccess, c.ID, NewField(FieldUserAccess, newAccessLvl))
- res = append(res, newT)
-
- if c.Authorize(AccessDisconUser) {
- c.Flags.Set(UserFlagAdmin, 1)
- } else {
- c.Flags.Set(UserFlagAdmin, 0)
- }
-
- c.Account.Access = account.Access
-
- cc.SendAll(
- TranNotifyChangeUser,
- NewField(FieldUserID, c.ID[:]),
- NewField(FieldUserFlags, c.Flags[:]),
- NewField(FieldUserName, c.UserName),
- NewField(FieldUserIconID, c.Icon),
- )
- }
- }
-
- return append(res, cc.NewReply(t))
-}
-
-func HandleGetUser(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessOpenUser) {
- return cc.NewErrReply(t, "You are not allowed to view accounts.")
- }
-
- account := cc.Server.AccountManager.Get(string(t.GetField(FieldUserLogin).Data))
- if account == nil {
- return cc.NewErrReply(t, "Account does not exist.")
- }
-
- return append(res, cc.NewReply(t,
- NewField(FieldUserName, []byte(account.Name)),
- NewField(FieldUserLogin, encodeString(t.GetField(FieldUserLogin).Data)),
- NewField(FieldUserPassword, []byte(account.Password)),
- NewField(FieldUserAccess, account.Access[:]),
- ))
-}
-
-func HandleListUsers(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessOpenUser) {
- return cc.NewErrReply(t, "You are not allowed to view accounts.")
- }
-
- var userFields []Field
- for _, acc := range cc.Server.AccountManager.List() {
- b, err := io.ReadAll(&acc)
- if err != nil {
- cc.logger.Error("Error reading account", "Account", acc.Login, "Err", err)
- continue
- }
-
- userFields = append(userFields, NewField(FieldData, b))
- }
-
- return append(res, cc.NewReply(t, userFields...))
-}
-
-// HandleUpdateUser is used by the v1.5+ multi-user editor to perform account editing for multiple users at a time.
-// An update can be a mix of these actions:
-// * Create user
-// * Delete user
-// * Modify user (including renaming the account login)
-//
-// The Transaction sent by the client includes one data field per user that was modified. This data field in turn
-// contains another data field encoded in its payload with a varying number of sub fields depending on which action is
-// performed. This seems to be the only place in the Hotline protocol where a data field contains another data field.
-func HandleUpdateUser(cc *ClientConn, t *Transaction) (res []Transaction) {
- for _, field := range t.Fields {
- var subFields []Field
-
- // Create a new scanner for parsing incoming bytes into transaction tokens
- scanner := bufio.NewScanner(bytes.NewReader(field.Data[2:]))
- scanner.Split(fieldScanner)
-
- for i := 0; i < int(binary.BigEndian.Uint16(field.Data[0:2])); i++ {
- scanner.Scan()
-
- var field Field
- if _, err := field.Write(scanner.Bytes()); err != nil {
- return res
- }
- subFields = append(subFields, field)
- }
-
- // If there's only one subfield, that indicates this is a delete operation for the login in FieldData
- if len(subFields) == 1 {
- if !cc.Authorize(AccessDeleteUser) {
- return cc.NewErrReply(t, "You are not allowed to delete accounts.")
- }
-
- login := string(encodeString(getField(FieldData, &subFields).Data))
-
- cc.logger.Info("DeleteUser", "login", login)
-
- if err := cc.Server.AccountManager.Delete(login); err != nil {
- cc.logger.Error("Error deleting account", "Err", err)
- return res
- }
-
- for _, client := range cc.Server.ClientMgr.List() {
- if client.Account.Login == login {
- // "You are logged in with an account which was deleted."
-
- res = append(res,
- NewTransaction(TranServerMsg, [2]byte{},
- NewField(FieldData, []byte("You are logged in with an account which was deleted.")),
- NewField(FieldChatOptions, []byte{0}),
- ),
- )
-
- go func(c *ClientConn) {
- time.Sleep(3 * time.Second)
- c.Disconnect()
- }(client)
- }
- }
-
- continue
- }
-
- // login of the account to update
- var accountToUpdate, loginToRename string
-
- // If FieldData is included, this is a rename operation where FieldData contains the login of the existing
- // account and FieldUserLogin contains the new login.
- if getField(FieldData, &subFields) != nil {
- loginToRename = string(encodeString(getField(FieldData, &subFields).Data))
- }
- userLogin := string(encodeString(getField(FieldUserLogin, &subFields).Data))
- if loginToRename != "" {
- accountToUpdate = loginToRename
- } else {
- accountToUpdate = userLogin
- }
-
- // Check if accountToUpdate has an existing account. If so, we know we are updating an existing user.
- if acc := cc.Server.AccountManager.Get(accountToUpdate); acc != nil {
- if loginToRename != "" {
- cc.logger.Info("RenameUser", "prevLogin", accountToUpdate, "newLogin", userLogin)
- } else {
- cc.logger.Info("UpdateUser", "login", accountToUpdate)
- }
-
- // Account exists, so this is an update action.
- if !cc.Authorize(AccessModifyUser) {
- return cc.NewErrReply(t, "You are not allowed to modify accounts.")
- }
-
- // This part is a bit tricky. There are three possibilities:
- // 1) The transaction is intended to update the password.
- // In this case, FieldUserPassword is sent with the new password.
- // 2) The transaction is intended to remove the password.
- // In this case, FieldUserPassword is not sent.
- // 3) The transaction updates the users access bits, but not the password.
- // In this case, FieldUserPassword is sent with zero as the only byte.
- if getField(FieldUserPassword, &subFields) != nil {
- newPass := getField(FieldUserPassword, &subFields).Data
- if !bytes.Equal([]byte{0}, newPass) {
- acc.Password = hashAndSalt(newPass)
- }
- } else {
- acc.Password = hashAndSalt([]byte(""))
- }
-
- if getField(FieldUserAccess, &subFields) != nil {
- copy(acc.Access[:], getField(FieldUserAccess, &subFields).Data)
- }
-
- acc.Name = string(getField(FieldUserName, &subFields).Data)
-
- err := cc.Server.AccountManager.Update(*acc, string(encodeString(getField(FieldUserLogin, &subFields).Data)))
-
- if err != nil {
- return res
- }
- } else {
- if !cc.Authorize(AccessCreateUser) {
- return cc.NewErrReply(t, "You are not allowed to create new accounts.")
- }
-
- cc.logger.Info("CreateUser", "login", userLogin)
-
- newAccess := accessBitmap{}
- copy(newAccess[:], getField(FieldUserAccess, &subFields).Data)
-
- // Prevent account from creating new account with greater permission
- for i := 0; i < 64; i++ {
- if newAccess.IsSet(i) {
- if !cc.Authorize(i) {
- return cc.NewErrReply(t, "Cannot create account with more access than yourself.")
- }
- }
- }
-
- account := NewAccount(userLogin, string(getField(FieldUserName, &subFields).Data), string(getField(FieldUserPassword, &subFields).Data), newAccess)
-
- err := cc.Server.AccountManager.Create(*account)
- if err != nil {
- return cc.NewErrReply(t, "Cannot create account because there is already an account with that login.")
- }
- }
- }
-
- return append(res, cc.NewReply(t))
-}
-
-// HandleNewUser creates a new user account
-func HandleNewUser(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessCreateUser) {
- return cc.NewErrReply(t, "You are not allowed to create new accounts.")
- }
-
- login := t.GetField(FieldUserLogin).DecodeObfuscatedString()
-
- // If the account already exists, reply with an error.
- if account := cc.Server.AccountManager.Get(login); account != nil {
- return cc.NewErrReply(t, "Cannot create account "+login+" because there is already an account with that login.")
- }
-
- var newAccess accessBitmap
- copy(newAccess[:], t.GetField(FieldUserAccess).Data)
-
- // Prevent account from creating new account with greater permission
- for i := 0; i < 64; i++ {
- if newAccess.IsSet(i) {
- if !cc.Authorize(i) {
- return cc.NewErrReply(t, "Cannot create account with more access than yourself.")
- }
- }
- }
-
- account := NewAccount(login, string(t.GetField(FieldUserName).Data), string(t.GetField(FieldUserPassword).Data), newAccess)
-
- err := cc.Server.AccountManager.Create(*account)
- if err != nil {
- return cc.NewErrReply(t, "Cannot create account because there is already an account with that login.")
- }
-
- return append(res, cc.NewReply(t))
-}
-
-func HandleDeleteUser(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessDeleteUser) {
- return cc.NewErrReply(t, "You are not allowed to delete accounts.")
- }
-
- login := t.GetField(FieldUserLogin).DecodeObfuscatedString()
-
- if err := cc.Server.AccountManager.Delete(login); err != nil {
- cc.logger.Error("Error deleting account", "Err", err)
- return res
- }
-
- for _, client := range cc.Server.ClientMgr.List() {
- if client.Account.Login == login {
- res = append(res,
- NewTransaction(TranServerMsg, client.ID,
- NewField(FieldData, []byte("You are logged in with an account which was deleted.")),
- NewField(FieldChatOptions, []byte{2}),
- ),
- )
-
- go func(c *ClientConn) {
- time.Sleep(2 * time.Second)
- c.Disconnect()
- }(client)
- }
- }
-
- return append(res, cc.NewReply(t))
-}
-
-// HandleUserBroadcast sends an Administrator Message to all connected clients of the server
-func HandleUserBroadcast(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessBroadcast) {
- return cc.NewErrReply(t, "You are not allowed to send broadcast messages.")
- }
-
- cc.SendAll(
- TranServerMsg,
- NewField(FieldData, t.GetField(FieldData).Data),
- NewField(FieldChatOptions, []byte{0}),
- )
-
- return append(res, cc.NewReply(t))
-}
-
-// HandleGetClientInfoText returns user information for the specific user.
-//
-// Fields used in the request:
-// 103 User Type
-//
-// Fields used in the reply:
-// 102 User Name
-// 101 Data User info text string
-func HandleGetClientInfoText(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessGetClientInfo) {
- return cc.NewErrReply(t, "You are not allowed to get client info.")
- }
-
- clientID := t.GetField(FieldUserID).Data
-
- clientConn := cc.Server.ClientMgr.Get(ClientID(clientID))
- if clientConn == nil {
- return cc.NewErrReply(t, "User not found.")
- }
-
- return append(res, cc.NewReply(t,
- NewField(FieldData, []byte(clientConn.String())),
- NewField(FieldUserName, clientConn.UserName),
- ))
-}
-
-func HandleGetUserNameList(cc *ClientConn, t *Transaction) (res []Transaction) {
- var fields []Field
- for _, c := range cc.Server.ClientMgr.List() {
- b, err := io.ReadAll(&User{
- ID: c.ID,
- Icon: c.Icon,
- Flags: c.Flags[:],
- Name: string(c.UserName),
- })
- if err != nil {
- return nil
- }
-
- fields = append(fields, NewField(FieldUsernameWithInfo, b))
- }
-
- return []Transaction{cc.NewReply(t, fields...)}
-}
-
-func HandleTranAgreed(cc *ClientConn, t *Transaction) (res []Transaction) {
- if t.GetField(FieldUserName).Data != nil {
- if cc.Authorize(AccessAnyName) {
- cc.UserName = t.GetField(FieldUserName).Data
- } else {
- cc.UserName = []byte(cc.Account.Name)
- }
- }
-
- cc.Icon = t.GetField(FieldUserIconID).Data
-
- cc.logger = cc.logger.With("Name", string(cc.UserName))
- cc.logger.Info("Login successful")
-
- options := t.GetField(FieldOptions).Data
- optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options)))
-
- // Check refuse private PM option
-
- cc.flagsMU.Lock()
- defer cc.flagsMU.Unlock()
- cc.Flags.Set(UserFlagRefusePM, optBitmap.Bit(UserOptRefusePM))
-
- // Check refuse private chat option
- cc.Flags.Set(UserFlagRefusePChat, optBitmap.Bit(UserOptRefuseChat))
-
- // Check auto response
- if optBitmap.Bit(UserOptAutoResponse) == 1 {
- cc.AutoReply = t.GetField(FieldAutomaticResponse).Data
- }
-
- trans := cc.NotifyOthers(
- NewTransaction(
- TranNotifyChangeUser, [2]byte{0, 0},
- NewField(FieldUserName, cc.UserName),
- NewField(FieldUserID, cc.ID[:]),
- NewField(FieldUserIconID, cc.Icon),
- NewField(FieldUserFlags, cc.Flags[:]),
- ),
- )
- res = append(res, trans...)
-
- if cc.Server.Config.BannerFile != "" {
- res = append(res, NewTransaction(TranServerBanner, cc.ID, NewField(FieldBannerType, []byte("JPEG"))))
- }
-
- res = append(res, cc.NewReply(t))
-
- return res
-}
-
-// HandleTranOldPostNews updates the flat news
-// Fields used in this request:
-// 101 Data
-func HandleTranOldPostNews(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessNewsPostArt) {
- return cc.NewErrReply(t, "You are not allowed to post news.")
- }
-
- newsDateTemplate := defaultNewsDateFormat
- if cc.Server.Config.NewsDateFormat != "" {
- newsDateTemplate = cc.Server.Config.NewsDateFormat
- }
-
- newsTemplate := defaultNewsTemplate
- if cc.Server.Config.NewsDelimiter != "" {
- newsTemplate = cc.Server.Config.NewsDelimiter
- }
-
- newsPost := fmt.Sprintf(newsTemplate+"\r", cc.UserName, time.Now().Format(newsDateTemplate), t.GetField(FieldData).Data)
- newsPost = strings.ReplaceAll(newsPost, "\n", "\r")
-
- _, err := cc.Server.MessageBoard.Write([]byte(newsPost))
- if err != nil {
- cc.logger.Error("error writing news post", "err", err)
- return nil
- }
-
- // Notify all clients of updated news
- cc.SendAll(
- TranNewMsg,
- NewField(FieldData, []byte(newsPost)),
- )
-
- return append(res, cc.NewReply(t))
-}
-
-func HandleDisconnectUser(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessDisconUser) {
- return cc.NewErrReply(t, "You are not allowed to disconnect users.")
- }
-
- clientID := [2]byte(t.GetField(FieldUserID).Data)
- clientConn := cc.Server.ClientMgr.Get(clientID)
-
- if clientConn.Authorize(AccessCannotBeDiscon) {
- return cc.NewErrReply(t, clientConn.Account.Login+" is not allowed to be disconnected.")
- }
-
- // If FieldOptions is set, then the client IP is banned in addition to disconnected.
- // 00 01 = temporary ban
- // 00 02 = permanent ban
- if t.GetField(FieldOptions).Data != nil {
- switch t.GetField(FieldOptions).Data[1] {
- case 1:
- // send message: "You are temporarily banned on this server"
- cc.logger.Info("Disconnect & temporarily ban " + string(clientConn.UserName))
-
- res = append(res, NewTransaction(
- TranServerMsg,
- clientConn.ID,
- NewField(FieldData, []byte("You are temporarily banned on this server")),
- NewField(FieldChatOptions, []byte{0, 0}),
- ))
-
- banUntil := time.Now().Add(tempBanDuration)
- ip := strings.Split(clientConn.RemoteAddr, ":")[0]
-
- err := cc.Server.BanList.Add(ip, &banUntil)
- if err != nil {
- cc.logger.Error("Error saving ban", "err", err)
- // TODO
- }
- case 2:
- // send message: "You are permanently banned on this server"
- cc.logger.Info("Disconnect & ban " + string(clientConn.UserName))
-
- res = append(res, NewTransaction(
- TranServerMsg,
- clientConn.ID,
- NewField(FieldData, []byte("You are permanently banned on this server")),
- NewField(FieldChatOptions, []byte{0, 0}),
- ))
-
- ip := strings.Split(clientConn.RemoteAddr, ":")[0]
-
- err := cc.Server.BanList.Add(ip, nil)
- if err != nil {
- // TODO
- }
- }
- }
-
- // TODO: remove this awful hack
- go func() {
- time.Sleep(1 * time.Second)
- clientConn.Disconnect()
- }()
-
- return append(res, cc.NewReply(t))
-}
-
-// HandleGetNewsCatNameList returns a list of news categories for a path
-// Fields used in the request:
-// 325 News path (Optional)
-func HandleGetNewsCatNameList(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessNewsReadArt) {
- return cc.NewErrReply(t, "You are not allowed to read news.")
- }
-
- pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
- if err != nil {
-
- }
-
- var fields []Field
- for _, cat := range cc.Server.ThreadedNewsMgr.GetCategories(pathStrs) {
- b, err := io.ReadAll(&cat)
- if err != nil {
- // TODO
- }
-
- fields = append(fields, NewField(FieldNewsCatListData15, b))
- }
-
- return append(res, cc.NewReply(t, fields...))
-}
-
-func HandleNewNewsCat(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessNewsCreateCat) {
- return cc.NewErrReply(t, "You are not allowed to create news categories.")
- }
-
- name := string(t.GetField(FieldNewsCatName).Data)
- pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
- if err != nil {
- return res
- }
-
- err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, NewsCategory)
- if err != nil {
- cc.logger.Error("error creating news category", "err", err)
- }
-
- return []Transaction{cc.NewReply(t)}
-}
-
-// Fields used in the request:
-// 322 News category Name
-// 325 News path
-func HandleNewNewsFldr(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessNewsCreateFldr) {
- return cc.NewErrReply(t, "You are not allowed to create news folders.")
- }
-
- name := string(t.GetField(FieldFileName).Data)
- pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
- if err != nil {
- return res
- }
-
- err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, NewsBundle)
- if err != nil {
- cc.logger.Error("error creating news bundle", "err", err)
- }
-
- return append(res, cc.NewReply(t))
-}
-
-// HandleGetNewsArtData gets the list of article names at the specified news path.
-
-// Fields used in the request:
-// 325 News path Optional
-
-// Fields used in the reply:
-// 321 News article list data Optional
-func HandleGetNewsArtNameList(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessNewsReadArt) {
- return cc.NewErrReply(t, "You are not allowed to read news.")
- }
-
- pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
- if err != nil {
- return res
- }
-
- nald := cc.Server.ThreadedNewsMgr.ListArticles(pathStrs)
-
- b, err := io.ReadAll(&nald)
- if err != nil {
- return res
- }
-
- return append(res, cc.NewReply(t, NewField(FieldNewsArtListData, b)))
-}
-
-// HandleGetNewsArtData requests information about the specific news article.
-// Fields used in the request:
-//
-// Request fields
-// 325 News path
-// 326 News article Type
-// 327 News article data flavor
-//
-// Fields used in the reply:
-// 328 News article title
-// 329 News article poster
-// 330 News article date
-// 331 Previous article Type
-// 332 Next article Type
-// 335 Parent article Type
-// 336 First child article Type
-// 327 News article data flavor "Should be “text/plain”
-// 333 News article data Optional (if data flavor is “text/plain”)
-func HandleGetNewsArtData(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessNewsReadArt) {
- return cc.NewErrReply(t, "You are not allowed to read news.")
- }
-
- newsPath, err := t.GetField(FieldNewsPath).DecodeNewsPath()
- if err != nil {
- return res
- }
-
- convertedID, err := t.GetField(FieldNewsArtID).DecodeInt()
- if err != nil {
- return res
- }
-
- art := cc.Server.ThreadedNewsMgr.GetArticle(newsPath, uint32(convertedID))
- if art == nil {
- return append(res, cc.NewReply(t))
- }
-
- res = append(res, cc.NewReply(t,
- NewField(FieldNewsArtTitle, []byte(art.Title)),
- NewField(FieldNewsArtPoster, []byte(art.Poster)),
- NewField(FieldNewsArtDate, art.Date[:]),
- NewField(FieldNewsArtPrevArt, art.PrevArt[:]),
- NewField(FieldNewsArtNextArt, art.NextArt[:]),
- NewField(FieldNewsArtParentArt, art.ParentArt[:]),
- NewField(FieldNewsArt1stChildArt, art.FirstChildArt[:]),
- NewField(FieldNewsArtDataFlav, []byte("text/plain")),
- NewField(FieldNewsArtData, []byte(art.Data)),
- ))
- return res
-}
-
-// HandleDelNewsItem deletes a threaded news folder or category.
-// Fields used in the request:
-// 325 News path
-// Fields used in the reply:
-// None
-func HandleDelNewsItem(cc *ClientConn, t *Transaction) (res []Transaction) {
- pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
- if err != nil {
- return res
- }
-
- item := cc.Server.ThreadedNewsMgr.NewsItem(pathStrs)
-
- if item.Type == [2]byte{0, 3} {
- if !cc.Authorize(AccessNewsDeleteCat) {
- return cc.NewErrReply(t, "You are not allowed to delete news categories.")
- }
- } else {
- if !cc.Authorize(AccessNewsDeleteFldr) {
- return cc.NewErrReply(t, "You are not allowed to delete news folders.")
- }
- }
-
- err = cc.Server.ThreadedNewsMgr.DeleteNewsItem(pathStrs)
- if err != nil {
- return res
- }
-
- return append(res, cc.NewReply(t))
-}
-
-// HandleDelNewsArt deletes a threaded news article.
-// Request Fields
-// 325 News path
-// 326 News article Type
-// 337 News article recursive delete - Delete child articles (1) or not (0)
-func HandleDelNewsArt(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessNewsDeleteArt) {
- return cc.NewErrReply(t, "You are not allowed to delete news articles.")
-
- }
-
- pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
- if err != nil {
- return res
- }
-
- articleID, err := t.GetField(FieldNewsArtID).DecodeInt()
- if err != nil {
- cc.logger.Error("error reading article Type", "err", err)
- return
- }
-
- deleteRecursive := bytes.Equal([]byte{0, 1}, t.GetField(FieldNewsArtRecurseDel).Data)
-
- err = cc.Server.ThreadedNewsMgr.DeleteArticle(pathStrs, uint32(articleID), deleteRecursive)
- if err != nil {
- cc.logger.Error("error deleting news article", "err", err)
- }
-
- return []Transaction{cc.NewReply(t)}
-}
-
-// Request fields
-// 325 News path
-// 326 News article Type Type of the parent article?
-// 328 News article title
-// 334 News article flags
-// 327 News article data flavor Currently “text/plain”
-// 333 News article data
-func HandlePostNewsArt(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessNewsPostArt) {
- return cc.NewErrReply(t, "You are not allowed to post news articles.")
- }
-
- pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
- if err != nil {
- return res
- }
-
- parentArticleID, err := t.GetField(FieldNewsArtID).DecodeInt()
- if err != nil {
- return res
- }
-
- err = cc.Server.ThreadedNewsMgr.PostArticle(
- pathStrs,
- uint32(parentArticleID),
- NewsArtData{
- Title: string(t.GetField(FieldNewsArtTitle).Data),
- Poster: string(cc.UserName),
- Date: toHotlineTime(time.Now()),
- DataFlav: NewsFlavor,
- Data: string(t.GetField(FieldNewsArtData).Data),
- },
- )
- if err != nil {
- cc.logger.Error("error posting news article", "err", err)
- }
-
- return append(res, cc.NewReply(t))
-}
-
-// HandleGetMsgs returns the flat news data
-func HandleGetMsgs(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessNewsReadArt) {
- return cc.NewErrReply(t, "You are not allowed to read news.")
- }
-
- _, _ = cc.Server.MessageBoard.Seek(0, 0)
-
- newsData, err := io.ReadAll(cc.Server.MessageBoard)
- if err != nil {
- // TODO
- }
-
- return append(res, cc.NewReply(t, NewField(FieldData, newsData)))
-}
-
-func HandleDownloadFile(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessDownloadFile) {
- return cc.NewErrReply(t, "You are not allowed to download files.")
- }
-
- fileName := t.GetField(FieldFileName).Data
- filePath := t.GetField(FieldFilePath).Data
- resumeData := t.GetField(FieldFileResumeData).Data
-
- var dataOffset int64
- var frd FileResumeData
- if resumeData != nil {
- if err := frd.UnmarshalBinary(t.GetField(FieldFileResumeData).Data); err != nil {
- return res
- }
- // TODO: handle rsrc fork offset
- dataOffset = int64(binary.BigEndian.Uint32(frd.ForkInfoList[0].DataSize[:]))
- }
-
- fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
- if err != nil {
- return res
- }
-
- hlFile, err := newFileWrapper(cc.Server.FS, fullFilePath, dataOffset)
- if err != nil {
- return res
- }
-
- xferSize := hlFile.ffo.TransferSize(0)
-
- ft := cc.newFileTransfer(FileDownload, fileName, filePath, xferSize)
-
- // TODO: refactor to remove this
- if resumeData != nil {
- var frd FileResumeData
- if err := frd.UnmarshalBinary(t.GetField(FieldFileResumeData).Data); err != nil {
- return res
- }
- ft.fileResumeData = &frd
- }
-
- // Optional field for when a client requests file preview
- // Used only for TEXT, JPEG, GIFF, BMP or PICT files
- // The value will always be 2
- if t.GetField(FieldFileTransferOptions).Data != nil {
- ft.options = t.GetField(FieldFileTransferOptions).Data
- xferSize = hlFile.ffo.FlatFileDataForkHeader.DataSize[:]
- }
-
- res = append(res, cc.NewReply(t,
- NewField(FieldRefNum, ft.refNum[:]),
- NewField(FieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count
- NewField(FieldTransferSize, xferSize),
- NewField(FieldFileSize, hlFile.ffo.FlatFileDataForkHeader.DataSize[:]),
- ))
-
- return res
-}
-
-// Download all files from the specified folder and sub-folders
-func HandleDownloadFolder(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessDownloadFile) {
- return cc.NewErrReply(t, "You are not allowed to download folders.")
- }
-
- fullFilePath, err := readPath(cc.Server.Config.FileRoot, t.GetField(FieldFilePath).Data, t.GetField(FieldFileName).Data)
- if err != nil {
- return res
- }
-
- transferSize, err := CalcTotalSize(fullFilePath)
- if err != nil {
- return res
- }
- itemCount, err := CalcItemCount(fullFilePath)
- if err != nil {
- return res
- }
-
- fileTransfer := cc.newFileTransfer(FolderDownload, t.GetField(FieldFileName).Data, t.GetField(FieldFilePath).Data, transferSize)
-
- var fp FilePath
- _, err = fp.Write(t.GetField(FieldFilePath).Data)
- if err != nil {
- return res
- }
-
- res = append(res, cc.NewReply(t,
- NewField(FieldRefNum, fileTransfer.refNum[:]),
- NewField(FieldTransferSize, transferSize),
- NewField(FieldFolderItemCount, itemCount),
- NewField(FieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count
- ))
- return res
-}
-
-// Upload all files from the local folder and its subfolders to the specified path on the server
-// Fields used in the request
-// 201 File Name
-// 202 File path
-// 108 transfer size Total size of all items in the folder
-// 220 Folder item count
-// 204 File transfer options "Optional Currently set to 1" (TODO: ??)
-func HandleUploadFolder(cc *ClientConn, t *Transaction) (res []Transaction) {
- var fp FilePath
- if t.GetField(FieldFilePath).Data != nil {
- if _, err := fp.Write(t.GetField(FieldFilePath).Data); err != nil {
- return res
- }
- }
-
- // Handle special cases for Upload and Drop Box folders
- if !cc.Authorize(AccessUploadAnywhere) {
- if !fp.IsUploadDir() && !fp.IsDropbox() {
- return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload of the folder \"%v\" because you are only allowed to upload to the \"Uploads\" folder.", string(t.GetField(FieldFileName).Data)))
- }
- }
-
- fileTransfer := cc.newFileTransfer(FolderUpload,
- t.GetField(FieldFileName).Data,
- t.GetField(FieldFilePath).Data,
- t.GetField(FieldTransferSize).Data,
- )
-
- fileTransfer.FolderItemCount = t.GetField(FieldFolderItemCount).Data
-
- return append(res, cc.NewReply(t, NewField(FieldRefNum, fileTransfer.refNum[:])))
-}
-
-// HandleUploadFile
-// Fields used in the request:
-// 201 File Name
-// 202 File path
-// 204 File transfer options "Optional
-// Used only to resume download, currently has value 2"
-// 108 File transfer size "Optional used if download is not resumed"
-func HandleUploadFile(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessUploadFile) {
- return cc.NewErrReply(t, "You are not allowed to upload files.")
- }
-
- fileName := t.GetField(FieldFileName).Data
- filePath := t.GetField(FieldFilePath).Data
- transferOptions := t.GetField(FieldFileTransferOptions).Data
- transferSize := t.GetField(FieldTransferSize).Data // not sent for resume
-
- var fp FilePath
- if filePath != nil {
- if _, err := fp.Write(filePath); err != nil {
- return res
- }
- }
-
- // Handle special cases for Upload and Drop Box folders
- if !cc.Authorize(AccessUploadAnywhere) {
- if !fp.IsUploadDir() && !fp.IsDropbox() {
- return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload of the file \"%v\" because you are only allowed to upload to the \"Uploads\" folder.", string(fileName)))
- }
- }
- fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
- if err != nil {
- return res
- }
-
- if _, err := cc.Server.FS.Stat(fullFilePath); err == nil {
- return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload because there is already a file named \"%v\". Try choosing a different Name.", string(fileName)))
- }
-
- ft := cc.newFileTransfer(FileUpload, fileName, filePath, transferSize)
-
- replyT := cc.NewReply(t, NewField(FieldRefNum, ft.refNum[:]))
-
- // client has requested to resume a partially transferred file
- if transferOptions != nil {
- fileInfo, err := cc.Server.FS.Stat(fullFilePath + incompleteFileSuffix)
- if err != nil {
- return res
- }
-
- offset := make([]byte, 4)
- binary.BigEndian.PutUint32(offset, uint32(fileInfo.Size()))
-
- fileResumeData := NewFileResumeData([]ForkInfoList{
- *NewForkInfoList(offset),
- })
-
- b, _ := fileResumeData.BinaryMarshal()
-
- ft.TransferSize = offset
-
- replyT.Fields = append(replyT.Fields, NewField(FieldFileResumeData, b))
- }
-
- res = append(res, replyT)
- return res
-}
-
-func HandleSetClientUserInfo(cc *ClientConn, t *Transaction) (res []Transaction) {
- if len(t.GetField(FieldUserIconID).Data) == 4 {
- cc.Icon = t.GetField(FieldUserIconID).Data[2:]
- } else {
- cc.Icon = t.GetField(FieldUserIconID).Data
- }
- if cc.Authorize(AccessAnyName) {
- cc.UserName = t.GetField(FieldUserName).Data
- }
-
- // the options field is only passed by the client versions > 1.2.3.
- options := t.GetField(FieldOptions).Data
- if options != nil {
- optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options)))
-
- //flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(cc.Flags[:])))
- //flagBitmap.SetBit(flagBitmap, UserFlagRefusePM, optBitmap.Bit(UserOptRefusePM))
- //binary.BigEndian.PutUint16(cc.Flags[:], uint16(flagBitmap.Int64()))
-
- cc.Flags.Set(UserFlagRefusePM, optBitmap.Bit(UserOptRefusePM))
- cc.Flags.Set(UserFlagRefusePChat, optBitmap.Bit(UserOptRefuseChat))
- //
- //flagBitmap.SetBit(flagBitmap, UserFlagRefusePChat, optBitmap.Bit(UserOptRefuseChat))
- //binary.BigEndian.PutUint16(cc.Flags[:], uint16(flagBitmap.Int64()))
-
- // Check auto response
- if optBitmap.Bit(UserOptAutoResponse) == 1 {
- cc.AutoReply = t.GetField(FieldAutomaticResponse).Data
- } else {
- cc.AutoReply = []byte{}
- }
- }
-
- for _, c := range cc.Server.ClientMgr.List() {
- res = append(res, NewTransaction(
- TranNotifyChangeUser,
- c.ID,
- NewField(FieldUserID, cc.ID[:]),
- NewField(FieldUserIconID, cc.Icon),
- NewField(FieldUserFlags, cc.Flags[:]),
- NewField(FieldUserName, cc.UserName),
- ))
- }
-
- return res
-}
-
-// HandleKeepAlive responds to keepalive transactions with an empty reply
-// * HL 1.9.2 Client sends keepalive msg every 3 minutes
-// * HL 1.2.3 Client doesn't send keepalives
-func HandleKeepAlive(cc *ClientConn, t *Transaction) (res []Transaction) {
- res = append(res, cc.NewReply(t))
-
- return res
-}
-
-func HandleGetFileNameList(cc *ClientConn, t *Transaction) (res []Transaction) {
- fullPath, err := readPath(
- cc.Server.Config.FileRoot,
- t.GetField(FieldFilePath).Data,
- nil,
- )
- if err != nil {
- return res
- }
-
- var fp FilePath
- if t.GetField(FieldFilePath).Data != nil {
- if _, err = fp.Write(t.GetField(FieldFilePath).Data); err != nil {
- return res
- }
- }
-
- // Handle special case for drop box folders
- if fp.IsDropbox() && !cc.Authorize(AccessViewDropBoxes) {
- return cc.NewErrReply(t, "You are not allowed to view drop boxes.")
- }
-
- fileNames, err := getFileNameList(fullPath, cc.Server.Config.IgnoreFiles)
- if err != nil {
- return res
- }
-
- res = append(res, cc.NewReply(t, fileNames...))
-
- return res
-}
-
-// =================================
-// Hotline private chat flow
-// =================================
-// 1. ClientA sends TranInviteNewChat to server with user Type to invite
-// 2. Server creates new ChatID
-// 3. Server sends TranInviteToChat to invitee
-// 4. Server replies to ClientA with new Chat Type
-//
-// A dialog box pops up in the invitee client with options to accept or decline the invitation.
-// If Accepted is clicked:
-// 1. ClientB sends TranJoinChat with FieldChatID
-
-// HandleInviteNewChat invites users to new private chat
-func HandleInviteNewChat(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessOpenChat) {
- return cc.NewErrReply(t, "You are not allowed to request private chat.")
- }
-
- // Client to Invite
- targetID := t.GetField(FieldUserID).Data
-
- // Create a new chat with self as initial member.
- newChatID := cc.Server.ChatMgr.New(cc)
-
- // Check if target user has "Refuse private chat" flag
- targetClient := cc.Server.ClientMgr.Get([2]byte(targetID))
- flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(targetClient.Flags[:])))
- if flagBitmap.Bit(UserFlagRefusePChat) == 1 {
- res = append(res,
- NewTransaction(
- TranServerMsg,
- cc.ID,
- NewField(FieldData, []byte(string(targetClient.UserName)+" does not accept private chats.")),
- NewField(FieldUserName, targetClient.UserName),
- NewField(FieldUserID, targetClient.ID[:]),
- NewField(FieldOptions, []byte{0, 2}),
- ),
- )
- } else {
- res = append(res,
- NewTransaction(
- TranInviteToChat,
- [2]byte(targetID),
- NewField(FieldChatID, newChatID[:]),
- NewField(FieldUserName, cc.UserName),
- NewField(FieldUserID, cc.ID[:]),
- ),
- )
- }
-
- return append(
- res,
- cc.NewReply(t,
- NewField(FieldChatID, newChatID[:]),
- NewField(FieldUserName, cc.UserName),
- NewField(FieldUserID, cc.ID[:]),
- NewField(FieldUserIconID, cc.Icon),
- NewField(FieldUserFlags, cc.Flags[:]),
- ),
- )
-}
-
-func HandleInviteToChat(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessOpenChat) {
- return cc.NewErrReply(t, "You are not allowed to request private chat.")
- }
-
- // Client to Invite
- targetID := t.GetField(FieldUserID).Data
- chatID := t.GetField(FieldChatID).Data
-
- return []Transaction{
- NewTransaction(
- TranInviteToChat,
- [2]byte(targetID),
- NewField(FieldChatID, chatID),
- NewField(FieldUserName, cc.UserName),
- NewField(FieldUserID, cc.ID[:]),
- ),
- cc.NewReply(
- t,
- NewField(FieldChatID, chatID),
- NewField(FieldUserName, cc.UserName),
- NewField(FieldUserID, cc.ID[:]),
- NewField(FieldUserIconID, cc.Icon),
- NewField(FieldUserFlags, cc.Flags[:]),
- ),
- }
-}
-
-func HandleRejectChatInvite(cc *ClientConn, t *Transaction) (res []Transaction) {
- chatID := [4]byte(t.GetField(FieldChatID).Data)
-
- for _, c := range cc.Server.ChatMgr.Members(chatID) {
- res = append(res,
- NewTransaction(
- TranChatMsg,
- c.ID,
- NewField(FieldChatID, chatID[:]),
- NewField(FieldData, append(cc.UserName, []byte(" declined invitation to chat")...)),
- ),
- )
- }
-
- return res
-}
-
-// HandleJoinChat is sent from a v1.8+ Hotline client when the joins a private chat
-// Fields used in the reply:
-// * 115 Chat subject
-// * 300 User Name with info (Optional)
-// * 300 (more user names with info)
-func HandleJoinChat(cc *ClientConn, t *Transaction) (res []Transaction) {
- chatID := t.GetField(FieldChatID).Data
-
- // Send TranNotifyChatChangeUser to current members of the chat to inform of new user
- for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) {
- res = append(res,
- NewTransaction(
- TranNotifyChatChangeUser,
- c.ID,
- NewField(FieldChatID, chatID),
- NewField(FieldUserName, cc.UserName),
- NewField(FieldUserID, cc.ID[:]),
- NewField(FieldUserIconID, cc.Icon),
- NewField(FieldUserFlags, cc.Flags[:]),
- ),
- )
- }
-
- cc.Server.ChatMgr.Join(ChatID(chatID), cc)
-
- subject := cc.Server.ChatMgr.GetSubject(ChatID(chatID))
-
- replyFields := []Field{NewField(FieldChatSubject, []byte(subject))}
- for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) {
- b, err := io.ReadAll(&User{
- ID: c.ID,
- Icon: c.Icon,
- Flags: c.Flags[:],
- Name: string(c.UserName),
- })
- if err != nil {
- return res
- }
- replyFields = append(replyFields, NewField(FieldUsernameWithInfo, b))
- }
-
- return append(res, cc.NewReply(t, replyFields...))
-}
-
-// HandleLeaveChat is sent from a v1.8+ Hotline client when the user exits a private chat
-// Fields used in the request:
-// - 114 FieldChatID
-//
-// Reply is not expected.
-func HandleLeaveChat(cc *ClientConn, t *Transaction) (res []Transaction) {
- chatID := t.GetField(FieldChatID).Data
-
- cc.Server.ChatMgr.Leave([4]byte(chatID), cc.ID)
-
- // Notify members of the private chat that the user has left
- for _, c := range cc.Server.ChatMgr.Members(ChatID(chatID)) {
- res = append(res,
- NewTransaction(
- TranNotifyChatDeleteUser,
- c.ID,
- NewField(FieldChatID, chatID),
- NewField(FieldUserID, cc.ID[:]),
- ),
- )
- }
-
- return res
-}
-
-// HandleSetChatSubject is sent from a v1.8+ Hotline client when the user sets a private chat subject
-// Fields used in the request:
-// * 114 Chat Type
-// * 115 Chat subject
-// Reply is not expected.
-func HandleSetChatSubject(cc *ClientConn, t *Transaction) (res []Transaction) {
- chatID := t.GetField(FieldChatID).Data
-
- cc.Server.ChatMgr.SetSubject([4]byte(chatID), string(t.GetField(FieldChatSubject).Data))
-
- // Notify chat members of new subject.
- for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) {
- res = append(res,
- NewTransaction(
- TranNotifyChatSubject,
- c.ID,
- NewField(FieldChatID, chatID),
- NewField(FieldChatSubject, t.GetField(FieldChatSubject).Data),
- ),
- )
- }
-
- return res
-}
-
-// HandleMakeAlias makes a file alias using the specified path.
-// Fields used in the request:
-// 201 File Name
-// 202 File path
-// 212 File new path Destination path
-//
-// Fields used in the reply:
-// None
-func HandleMakeAlias(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(AccessMakeAlias) {
- return cc.NewErrReply(t, "You are not allowed to make aliases.")
- }
- fileName := t.GetField(FieldFileName).Data
- filePath := t.GetField(FieldFilePath).Data
- fileNewPath := t.GetField(FieldFileNewPath).Data
-
- fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
- if err != nil {
- return res
- }
-
- fullNewFilePath, err := readPath(cc.Server.Config.FileRoot, fileNewPath, fileName)
- if err != nil {
- return res
- }
-
- cc.logger.Debug("Make alias", "src", fullFilePath, "dst", fullNewFilePath)
-
- if err := cc.Server.FS.Symlink(fullFilePath, fullNewFilePath); err != nil {
- return cc.NewErrReply(t, "Error creating alias")
- }
-
- res = append(res, cc.NewReply(t))
- return res
-}
-
-// HandleDownloadBanner handles requests for a new banner from the server
-// Fields used in the request:
-// None
-// Fields used in the reply:
-// 107 FieldRefNum Used later for transfer
-// 108 FieldTransferSize Size of data to be downloaded
-func HandleDownloadBanner(cc *ClientConn, t *Transaction) (res []Transaction) {
- ft := cc.newFileTransfer(BannerDownload, []byte{}, []byte{}, make([]byte, 4))
- binary.BigEndian.PutUint32(ft.TransferSize, uint32(len(cc.Server.banner)))
-
- return append(res, cc.NewReply(t,
- NewField(FieldRefNum, ft.refNum[:]),
- NewField(FieldTransferSize, ft.TransferSize),
- ))
-}
+const LimitChatMsg = 8192
+++ /dev/null
-package hotline
-
-import (
- "errors"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/mock"
- "io"
- "io/fs"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-)
-
-func TestHandleSetChatSubject(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- want []Transaction
- }{
- {
- name: "sends chat subject to private chat members",
- args: args{
- cc: &ClientConn{
- UserName: []byte{0x00, 0x01},
- Server: &Server{
- ChatMgr: func() *MockChatManager {
- m := MockChatManager{}
- m.On("Members", ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*ClientConn{
- {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- },
- ID: [2]byte{0, 1},
- },
- {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- },
- ID: [2]byte{0, 2},
- },
- })
- m.On("SetSubject", ChatID{0x0, 0x0, 0x0, 0x1}, "Test Subject")
- return &m
- }(),
- //PrivateChats: map[[4]byte]*PrivateChat{
- // [4]byte{0, 0, 0, 1}: {
- // Subject: "unset",
- // ClientConn: map[[2]byte]*ClientConn{
- // [2]byte{0, 1}: {
- // Account: &Account{
- // Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- // },
- // ID: [2]byte{0, 1},
- // },
- // [2]byte{0, 2}: {
- // Account: &Account{
- // Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- // },
- // ID: [2]byte{0, 2},
- // },
- // },
- // },
- //},
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("List").Return([]*ClientConn{
- {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- },
- ID: [2]byte{0, 1},
- },
- {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- },
- ID: [2]byte{0, 2},
- },
- },
- )
- return &m
- }(),
- },
- },
- t: Transaction{
- Type: [2]byte{0, 0x6a},
- ID: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldChatID, []byte{0, 0, 0, 1}),
- NewField(FieldChatSubject, []byte("Test Subject")),
- },
- },
- },
- want: []Transaction{
- {
- clientID: [2]byte{0, 1},
- Type: [2]byte{0, 0x77},
- Fields: []Field{
- NewField(FieldChatID, []byte{0, 0, 0, 1}),
- NewField(FieldChatSubject, []byte("Test Subject")),
- },
- },
- {
- clientID: [2]byte{0, 2},
- Type: [2]byte{0, 0x77},
- Fields: []Field{
- NewField(FieldChatID, []byte{0, 0, 0, 1}),
- NewField(FieldChatSubject, []byte("Test Subject")),
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := HandleSetChatSubject(tt.args.cc, &tt.args.t)
- if !tranAssertEqual(t, tt.want, got) {
- t.Errorf("HandleSetChatSubject() got = %v, want %v", got, tt.want)
- }
- })
- }
-}
-
-func TestHandleLeaveChat(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- want []Transaction
- }{
- {
- name: "when client 2 leaves chat",
- args: args{
- cc: &ClientConn{
- ID: [2]byte{0, 2},
- Server: &Server{
- ChatMgr: func() *MockChatManager {
- m := MockChatManager{}
- m.On("Members", ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*ClientConn{
- {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- },
- ID: [2]byte{0, 1},
- },
- })
- m.On("Leave", ChatID{0x0, 0x0, 0x0, 0x1}, [2]uint8{0x0, 0x2})
- m.On("GetSubject").Return("unset")
- return &m
- }(),
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("Get").Return([]*ClientConn{
- {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- },
- ID: [2]byte{0, 1},
- },
- {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- },
- ID: [2]byte{0, 2},
- },
- },
- )
- return &m
- }(),
- },
- },
- t: NewTransaction(TranDeleteUser, [2]byte{}, NewField(FieldChatID, []byte{0, 0, 0, 1})),
- },
- want: []Transaction{
- {
- clientID: [2]byte{0, 1},
- Type: [2]byte{0, 0x76},
- Fields: []Field{
- NewField(FieldChatID, []byte{0, 0, 0, 1}),
- NewField(FieldUserID, []byte{0, 2}),
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := HandleLeaveChat(tt.args.cc, &tt.args.t)
- if !tranAssertEqual(t, tt.want, got) {
- t.Errorf("HandleLeaveChat() got = %v, want %v", got, tt.want)
- }
- })
- }
-}
-
-func TestHandleGetUserNameList(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- want []Transaction
- }{
- {
- name: "replies with userlist transaction",
- args: args{
- cc: &ClientConn{
- ID: [2]byte{0, 1},
- Server: &Server{
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("List").Return([]*ClientConn{
- {
- ID: [2]byte{0, 1},
- Icon: []byte{0, 2},
- Flags: [2]byte{0, 3},
- UserName: []byte{0, 4},
- },
- {
- ID: [2]byte{0, 2},
- Icon: []byte{0, 2},
- Flags: [2]byte{0, 3},
- UserName: []byte{0, 4},
- },
- },
- )
- return &m
- }(),
- },
- },
- t: Transaction{},
- },
- want: []Transaction{
- {
- clientID: [2]byte{0, 1},
- IsReply: 0x01,
- Fields: []Field{
- NewField(
- FieldUsernameWithInfo,
- []byte{00, 01, 00, 02, 00, 03, 00, 02, 00, 04},
- ),
- NewField(
- FieldUsernameWithInfo,
- []byte{00, 02, 00, 02, 00, 03, 00, 02, 00, 04},
- ),
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := HandleGetUserNameList(tt.args.cc, &tt.args.t)
- assert.Equal(t, tt.want, got)
- })
- }
-}
-
-func TestHandleChatSend(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- want []Transaction
- }{
- {
- name: "sends chat msg transaction to all clients",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessSendChat)
- return bits
- }(),
- },
- UserName: []byte{0x00, 0x01},
- Server: &Server{
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("List").Return([]*ClientConn{
- {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- },
- ID: [2]byte{0, 1},
- },
- {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- },
- ID: [2]byte{0, 2},
- },
- },
- )
- return &m
- }(),
- },
- },
- t: Transaction{
- Fields: []Field{
- NewField(FieldData, []byte("hai")),
- },
- },
- },
- want: []Transaction{
- {
- clientID: [2]byte{0, 1},
- Flags: 0x00,
- IsReply: 0x00,
- Type: [2]byte{0, 0x6a},
- Fields: []Field{
- NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
- },
- },
- {
- clientID: [2]byte{0, 2},
- Flags: 0x00,
- IsReply: 0x00,
- Type: [2]byte{0, 0x6a},
- Fields: []Field{
- NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
- },
- },
- },
- },
- {
- name: "treats Chat Type 00 00 00 00 as a public chat message",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessSendChat)
- return bits
- }(),
- },
- UserName: []byte{0x00, 0x01},
- Server: &Server{
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("List").Return([]*ClientConn{
- {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- },
- ID: [2]byte{0, 1},
- },
- {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- },
- ID: [2]byte{0, 2},
- },
- },
- )
- return &m
- }(),
- },
- },
- t: Transaction{
- Fields: []Field{
- NewField(FieldData, []byte("hai")),
- NewField(FieldChatID, []byte{0, 0, 0, 0}),
- },
- },
- },
- want: []Transaction{
- {
- clientID: [2]byte{0, 1},
- Type: [2]byte{0, 0x6a},
- Fields: []Field{
- NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
- },
- },
- {
- clientID: [2]byte{0, 2},
- Type: [2]byte{0, 0x6a},
- Fields: []Field{
- NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
- },
- },
- },
- },
- {
- name: "when user does not have required permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
- },
- Server: &Server{
- //Accounts: map[string]*Account{},
- },
- },
- t: NewTransaction(
- TranChatSend, [2]byte{0, 1},
- NewField(FieldData, []byte("hai")),
- ),
- },
- want: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to participate in chat.")),
- },
- },
- },
- },
- {
- name: "sends chat msg as emote if FieldChatOptions is set to 1",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessSendChat)
- return bits
- }(),
- },
- UserName: []byte("Testy McTest"),
- Server: &Server{
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("List").Return([]*ClientConn{
- {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- },
- ID: [2]byte{0, 1},
- },
- {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- },
- ID: [2]byte{0, 2},
- },
- },
- )
- return &m
- }(),
- },
- },
- t: Transaction{
- Fields: []Field{
- NewField(FieldData, []byte("performed action")),
- NewField(FieldChatOptions, []byte{0x00, 0x01}),
- },
- },
- },
- want: []Transaction{
- {
- clientID: [2]byte{0, 1},
- Flags: 0x00,
- IsReply: 0x00,
- Type: [2]byte{0, 0x6a},
- Fields: []Field{
- NewField(FieldData, []byte("\r*** Testy McTest performed action")),
- },
- },
- {
- clientID: [2]byte{0, 2},
- Flags: 0x00,
- IsReply: 0x00,
- Type: [2]byte{0, 0x6a},
- Fields: []Field{
- NewField(FieldData, []byte("\r*** Testy McTest performed action")),
- },
- },
- },
- },
- {
- name: "does not send chat msg as emote if FieldChatOptions is set to 0",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessSendChat)
- return bits
- }(),
- },
- UserName: []byte("Testy McTest"),
- Server: &Server{
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("List").Return([]*ClientConn{
- {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- },
- ID: [2]byte{0, 1},
- },
- {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- },
- ID: [2]byte{0, 2},
- },
- },
- )
- return &m
- }(),
- },
- },
- t: Transaction{
- Fields: []Field{
- NewField(FieldData, []byte("hello")),
- NewField(FieldChatOptions, []byte{0x00, 0x00}),
- },
- },
- },
- want: []Transaction{
- {
- clientID: [2]byte{0, 1},
- Type: [2]byte{0, 0x6a},
- Fields: []Field{
- NewField(FieldData, []byte("\r Testy McTest: hello")),
- },
- },
- {
- clientID: [2]byte{0, 2},
- Type: [2]byte{0, 0x6a},
- Fields: []Field{
- NewField(FieldData, []byte("\r Testy McTest: hello")),
- },
- },
- },
- },
- {
- name: "only sends chat msg to clients with AccessReadChat permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessSendChat)
- return bits
- }(),
- },
- UserName: []byte{0x00, 0x01},
- Server: &Server{
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("List").Return([]*ClientConn{
- {
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessReadChat)
- return bits
- }(),
- },
- ID: [2]byte{0, 1},
- },
- {
- Account: &Account{},
- ID: [2]byte{0, 2},
- },
- },
- )
- return &m
- }(),
- },
- },
- t: Transaction{
- Fields: []Field{
- NewField(FieldData, []byte("hai")),
- },
- },
- },
- want: []Transaction{
- {
- clientID: [2]byte{0, 1},
- Type: [2]byte{0, 0x6a},
- Fields: []Field{
- NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
- },
- },
- },
- },
- {
- name: "only sends private chat msg to members of private chat",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessSendChat)
- return bits
- }(),
- },
- UserName: []byte{0x00, 0x01},
- Server: &Server{
- ChatMgr: func() *MockChatManager {
- m := MockChatManager{}
- m.On("Members", ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*ClientConn{
- {
- ID: [2]byte{0, 1},
- },
- {
- ID: [2]byte{0, 2},
- },
- })
- m.On("GetSubject").Return("unset")
- return &m
- }(),
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("List").Return([]*ClientConn{
- {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- },
- ID: [2]byte{0, 1},
- },
- {
- Account: &Account{
- Access: accessBitmap{0, 0, 0, 0, 0, 0, 0, 0},
- },
- ID: [2]byte{0, 2},
- },
- {
- Account: &Account{
- Access: accessBitmap{0, 0, 0, 0, 0, 0, 0, 0},
- },
- ID: [2]byte{0, 3},
- },
- },
- )
- return &m
- }(),
- },
- },
- t: Transaction{
- Fields: []Field{
- NewField(FieldData, []byte("hai")),
- NewField(FieldChatID, []byte{0, 0, 0, 1}),
- },
- },
- },
- want: []Transaction{
- {
- clientID: [2]byte{0, 1},
- Type: [2]byte{0, 0x6a},
- Fields: []Field{
- NewField(FieldChatID, []byte{0, 0, 0, 1}),
- NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
- },
- },
- {
- clientID: [2]byte{0, 2},
- Type: [2]byte{0, 0x6a},
- Fields: []Field{
- NewField(FieldChatID, []byte{0, 0, 0, 1}),
- NewField(FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := HandleChatSend(tt.args.cc, &tt.args.t)
- tranAssertEqual(t, tt.want, got)
- })
- }
-}
-
-func TestHandleGetFileInfo(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "returns expected fields when a valid file is requested",
- args: args{
- cc: &ClientConn{
- ID: [2]byte{0x00, 0x01},
- Server: &Server{
- FS: &OSFileStore{},
- Config: Config{
- FileRoot: func() string {
- path, _ := os.Getwd()
- return filepath.Join(path, "/test/config/Files")
- }(),
- },
- },
- },
- t: NewTransaction(
- TranGetFileInfo, [2]byte{},
- NewField(FieldFileName, []byte("testfile.txt")),
- NewField(FieldFilePath, []byte{0x00, 0x00}),
- ),
- },
- wantRes: []Transaction{
- {
- clientID: [2]byte{0, 1},
- IsReply: 0x01,
- Type: [2]byte{0, 0},
- Fields: []Field{
- NewField(FieldFileName, []byte("testfile.txt")),
- NewField(FieldFileTypeString, []byte("Text File")),
- NewField(FieldFileCreatorString, []byte("ttxt")),
- NewField(FieldFileType, []byte("TEXT")),
- NewField(FieldFileCreateDate, make([]byte, 8)),
- NewField(FieldFileModifyDate, make([]byte, 8)),
- NewField(FieldFileSize, []byte{0x0, 0x0, 0x0, 0x17}),
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleGetFileInfo(tt.args.cc, &tt.args.t)
-
- // Clear the file timestamp fields to work around problems running the tests in multiple timezones
- // TODO: revisit how to test this by mocking the stat calls
- gotRes[0].Fields[4].Data = make([]byte, 8)
- gotRes[0].Fields[5].Data = make([]byte, 8)
-
- if !tranAssertEqual(t, tt.wantRes, gotRes) {
- t.Errorf("HandleGetFileInfo() gotRes = %v, want %v", gotRes, tt.wantRes)
- }
- })
- }
-}
-
-func TestHandleNewFolder(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "without required permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
- },
- },
- t: NewTransaction(
- TranNewFolder,
- [2]byte{0, 0},
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to create folders.")),
- },
- },
- },
- },
- {
- name: "when path is nested",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessCreateFolder)
- return bits
- }(),
- },
- ID: [2]byte{0, 1},
- Server: &Server{
- Config: Config{
- FileRoot: "/Files/",
- },
- FS: func() *MockFileStore {
- mfs := &MockFileStore{}
- mfs.On("Mkdir", "/Files/aaa/testFolder", fs.FileMode(0777)).Return(nil)
- mfs.On("Stat", "/Files/aaa/testFolder").Return(nil, os.ErrNotExist)
- return mfs
- }(),
- },
- },
- t: NewTransaction(
- TranNewFolder, [2]byte{0, 1},
- NewField(FieldFileName, []byte("testFolder")),
- NewField(FieldFilePath, []byte{
- 0x00, 0x01,
- 0x00, 0x00,
- 0x03,
- 0x61, 0x61, 0x61,
- }),
- ),
- },
- wantRes: []Transaction{
- {
- clientID: [2]byte{0, 1},
- IsReply: 0x01,
- },
- },
- },
- {
- name: "when path is not nested",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessCreateFolder)
- return bits
- }(),
- },
- ID: [2]byte{0, 1},
- Server: &Server{
- Config: Config{
- FileRoot: "/Files",
- },
- FS: func() *MockFileStore {
- mfs := &MockFileStore{}
- mfs.On("Mkdir", "/Files/testFolder", fs.FileMode(0777)).Return(nil)
- mfs.On("Stat", "/Files/testFolder").Return(nil, os.ErrNotExist)
- return mfs
- }(),
- },
- },
- t: NewTransaction(
- TranNewFolder, [2]byte{0, 1},
- NewField(FieldFileName, []byte("testFolder")),
- ),
- },
- wantRes: []Transaction{
- {
- clientID: [2]byte{0, 1},
- IsReply: 0x01,
- },
- },
- },
- {
- name: "when Write returns an err",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessCreateFolder)
- return bits
- }(),
- },
- ID: [2]byte{0, 1},
- Server: &Server{
- Config: Config{
- FileRoot: "/Files/",
- },
- FS: func() *MockFileStore {
- mfs := &MockFileStore{}
- mfs.On("Mkdir", "/Files/aaa/testFolder", fs.FileMode(0777)).Return(nil)
- mfs.On("Stat", "/Files/aaa/testFolder").Return(nil, os.ErrNotExist)
- return mfs
- }(),
- },
- },
- t: NewTransaction(
- TranNewFolder, [2]byte{0, 1},
- NewField(FieldFileName, []byte("testFolder")),
- NewField(FieldFilePath, []byte{
- 0x00,
- }),
- ),
- },
- wantRes: []Transaction{},
- },
- {
- name: "FieldFileName does not allow directory traversal",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessCreateFolder)
- return bits
- }(),
- },
- ID: [2]byte{0, 1},
- Server: &Server{
- Config: Config{
- FileRoot: "/Files/",
- },
- FS: func() *MockFileStore {
- mfs := &MockFileStore{}
- mfs.On("Mkdir", "/Files/testFolder", fs.FileMode(0777)).Return(nil)
- mfs.On("Stat", "/Files/testFolder").Return(nil, os.ErrNotExist)
- return mfs
- }(),
- },
- },
- t: NewTransaction(
- TranNewFolder, [2]byte{0, 1},
- NewField(FieldFileName, []byte("../../testFolder")),
- ),
- },
- wantRes: []Transaction{
- {
- clientID: [2]byte{0, 1},
- IsReply: 0x01,
- },
- },
- },
- {
- name: "FieldFilePath does not allow directory traversal",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessCreateFolder)
- return bits
- }(),
- },
- ID: [2]byte{0, 1},
- Server: &Server{
- Config: Config{
- FileRoot: "/Files/",
- },
- FS: func() *MockFileStore {
- mfs := &MockFileStore{}
- mfs.On("Mkdir", "/Files/foo/testFolder", fs.FileMode(0777)).Return(nil)
- mfs.On("Stat", "/Files/foo/testFolder").Return(nil, os.ErrNotExist)
- return mfs
- }(),
- },
- },
- t: NewTransaction(
- TranNewFolder, [2]byte{0, 1},
- NewField(FieldFileName, []byte("testFolder")),
- NewField(FieldFilePath, []byte{
- 0x00, 0x02,
- 0x00, 0x00,
- 0x03,
- 0x2e, 0x2e, 0x2f,
- 0x00, 0x00,
- 0x03,
- 0x66, 0x6f, 0x6f,
- }),
- ),
- },
- wantRes: []Transaction{
- {
- clientID: [2]byte{0, 1},
- IsReply: 0x01,
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleNewFolder(tt.args.cc, &tt.args.t)
-
- if !tranAssertEqual(t, tt.wantRes, gotRes) {
- t.Errorf("HandleNewFolder() gotRes = %v, want %v", gotRes, tt.wantRes)
- }
- })
- }
-}
-
-func TestHandleUploadFile(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "when request is valid and user has Upload Anywhere permission",
- args: args{
- cc: &ClientConn{
- Server: &Server{
- FS: &OSFileStore{},
- FileTransferMgr: NewMemFileTransferMgr(),
- Config: Config{
- FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(),
- }},
- ClientFileTransferMgr: NewClientFileTransferMgr(),
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessUploadFile)
- bits.Set(AccessUploadAnywhere)
- return bits
- }(),
- },
- },
- t: NewTransaction(
- TranUploadFile, [2]byte{0, 1},
- NewField(FieldFileName, []byte("testFile")),
- NewField(FieldFilePath, []byte{
- 0x00, 0x01,
- 0x00, 0x00,
- 0x03,
- 0x2e, 0x2e, 0x2f,
- }),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- Fields: []Field{
- NewField(FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}), // rand.Seed(1)
- },
- },
- },
- },
- {
- name: "when user does not have required access",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
- },
- },
- t: NewTransaction(
- TranUploadFile, [2]byte{0, 1},
- NewField(FieldFileName, []byte("testFile")),
- NewField(FieldFilePath, []byte{
- 0x00, 0x01,
- 0x00, 0x00,
- 0x03,
- 0x2e, 0x2e, 0x2f,
- }),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to upload files.")), // rand.Seed(1)
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleUploadFile(tt.args.cc, &tt.args.t)
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleMakeAlias(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "with valid input and required permissions",
- args: args{
- cc: &ClientConn{
- logger: NewTestLogger(),
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessMakeAlias)
- return bits
- }(),
- },
- Server: &Server{
- Config: Config{
- FileRoot: func() string {
- path, _ := os.Getwd()
- return path + "/test/config/Files"
- }(),
- },
- Logger: NewTestLogger(),
- FS: func() *MockFileStore {
- mfs := &MockFileStore{}
- path, _ := os.Getwd()
- mfs.On(
- "Symlink",
- path+"/test/config/Files/foo/testFile",
- path+"/test/config/Files/bar/testFile",
- ).Return(nil)
- return mfs
- }(),
- },
- },
- t: NewTransaction(
- TranMakeFileAlias, [2]byte{0, 1},
- NewField(FieldFileName, []byte("testFile")),
- NewField(FieldFilePath, EncodeFilePath(strings.Join([]string{"foo"}, "/"))),
- NewField(FieldFileNewPath, EncodeFilePath(strings.Join([]string{"bar"}, "/"))),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- Fields: []Field(nil),
- },
- },
- },
- {
- name: "when symlink returns an error",
- args: args{
- cc: &ClientConn{
- logger: NewTestLogger(),
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessMakeAlias)
- return bits
- }(),
- },
- Server: &Server{
- Config: Config{
- FileRoot: func() string {
- path, _ := os.Getwd()
- return path + "/test/config/Files"
- }(),
- },
- Logger: NewTestLogger(),
- FS: func() *MockFileStore {
- mfs := &MockFileStore{}
- path, _ := os.Getwd()
- mfs.On(
- "Symlink",
- path+"/test/config/Files/foo/testFile",
- path+"/test/config/Files/bar/testFile",
- ).Return(errors.New("ohno"))
- return mfs
- }(),
- },
- },
- t: NewTransaction(
- TranMakeFileAlias, [2]byte{0, 1},
- NewField(FieldFileName, []byte("testFile")),
- NewField(FieldFilePath, EncodeFilePath(strings.Join([]string{"foo"}, "/"))),
- NewField(FieldFileNewPath, EncodeFilePath(strings.Join([]string{"bar"}, "/"))),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("Error creating alias")),
- },
- },
- },
- },
- {
- name: "when user does not have required permission",
- args: args{
- cc: &ClientConn{
- logger: NewTestLogger(),
- Account: &Account{
- Access: accessBitmap{},
- },
- Server: &Server{
- Config: Config{
- FileRoot: func() string {
- path, _ := os.Getwd()
- return path + "/test/config/Files"
- }(),
- },
- },
- },
- t: NewTransaction(
- TranMakeFileAlias, [2]byte{0, 1},
- NewField(FieldFileName, []byte("testFile")),
- NewField(FieldFilePath, []byte{
- 0x00, 0x01,
- 0x00, 0x00,
- 0x03,
- 0x2e, 0x2e, 0x2e,
- }),
- NewField(FieldFileNewPath, []byte{
- 0x00, 0x01,
- 0x00, 0x00,
- 0x03,
- 0x2e, 0x2e, 0x2e,
- }),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to make aliases.")),
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleMakeAlias(tt.args.cc, &tt.args.t)
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleGetUser(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "when account is valid",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessOpenUser)
- return bits
- }(),
- },
- Server: &Server{
- AccountManager: func() *MockAccountManager {
- m := MockAccountManager{}
- m.On("Get", "guest").Return(&Account{
- Login: "guest",
- Name: "Guest",
- Password: "password",
- Access: accessBitmap{},
- })
- return &m
- }(),
- },
- },
- t: NewTransaction(
- TranGetUser, [2]byte{0, 1},
- NewField(FieldUserLogin, []byte("guest")),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- Fields: []Field{
- NewField(FieldUserName, []byte("Guest")),
- NewField(FieldUserLogin, encodeString([]byte("guest"))),
- NewField(FieldUserPassword, []byte("password")),
- NewField(FieldUserAccess, []byte{0, 0, 0, 0, 0, 0, 0, 0}),
- },
- },
- },
- },
- {
- name: "when user does not have required permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
- },
- Server: &Server{
- //Accounts: map[string]*Account{},
- },
- },
- t: NewTransaction(
- TranGetUser, [2]byte{0, 1},
- NewField(FieldUserLogin, []byte("nonExistentUser")),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to view accounts.")),
- },
- },
- },
- },
- {
- name: "when account does not exist",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessOpenUser)
- return bits
- }(),
- },
- Server: &Server{
- AccountManager: func() *MockAccountManager {
- m := MockAccountManager{}
- m.On("Get", "nonExistentUser").Return((*Account)(nil))
- return &m
- }(),
- },
- },
- t: NewTransaction(
- TranGetUser, [2]byte{0, 1},
- NewField(FieldUserLogin, []byte("nonExistentUser")),
- ),
- },
- wantRes: []Transaction{
- {
- Flags: 0x00,
- IsReply: 0x01,
- Type: [2]byte{0, 0},
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("Account does not exist.")),
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleGetUser(tt.args.cc, &tt.args.t)
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleDeleteUser(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "when user exists",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessDeleteUser)
- return bits
- }(),
- },
- Server: &Server{
- AccountManager: func() *MockAccountManager {
- m := MockAccountManager{}
- m.On("Delete", "testuser").Return(nil)
- return &m
- }(),
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("List").Return([]*ClientConn{}) // TODO
- return &m
- }(),
- },
- },
- t: NewTransaction(
- TranDeleteUser, [2]byte{0, 1},
- NewField(FieldUserLogin, encodeString([]byte("testuser"))),
- ),
- },
- wantRes: []Transaction{
- {
- Flags: 0x00,
- IsReply: 0x01,
- Type: [2]byte{0, 0},
- Fields: []Field(nil),
- },
- },
- },
- {
- name: "when user does not have required permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: accessBitmap{},
- },
- Server: &Server{
- //Accounts: map[string]*Account{},
- },
- },
- t: NewTransaction(
- TranDeleteUser, [2]byte{0, 1},
- NewField(FieldUserLogin, encodeString([]byte("testuser"))),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to delete accounts.")),
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleDeleteUser(tt.args.cc, &tt.args.t)
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleGetMsgs(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "returns news data",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessNewsReadArt)
- return bits
- }(),
- },
- Server: &Server{
- MessageBoard: func() *mockReadWriteSeeker {
- m := mockReadWriteSeeker{}
- m.On("Seek", int64(0), 0).Return(int64(0), nil)
- m.On("Read", mock.AnythingOfType("[]uint8")).Run(func(args mock.Arguments) {
- arg := args.Get(0).([]uint8)
- copy(arg, "TEST")
- }).Return(4, io.EOF)
- return &m
- }(),
- },
- },
- t: NewTransaction(
- TranGetMsgs, [2]byte{0, 1},
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- Fields: []Field{
- NewField(FieldData, []byte("TEST")),
- },
- },
- },
- },
- {
- name: "when user does not have required permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: accessBitmap{},
- },
- Server: &Server{
- //Accounts: map[string]*Account{},
- },
- },
- t: NewTransaction(
- TranGetMsgs, [2]byte{0, 1},
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to read news.")),
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleGetMsgs(tt.args.cc, &tt.args.t)
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleNewUser(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "when user does not have required permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
- },
- Server: &Server{
- //Accounts: map[string]*Account{},
- },
- },
- t: NewTransaction(
- TranNewUser, [2]byte{0, 1},
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to create new accounts.")),
- },
- },
- },
- },
- {
- name: "when user attempts to create account with greater access",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessCreateUser)
- return bits
- }(),
- },
- Server: &Server{
- AccountManager: func() *MockAccountManager {
- m := MockAccountManager{}
- m.On("Get", "userB").Return((*Account)(nil))
- return &m
- }(),
- },
- },
- t: NewTransaction(
- TranNewUser, [2]byte{0, 1},
- NewField(FieldUserLogin, encodeString([]byte("userB"))),
- NewField(
- FieldUserAccess,
- func() []byte {
- var bits accessBitmap
- bits.Set(AccessDisconUser)
- return bits[:]
- }(),
- ),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("Cannot create account with more access than yourself.")),
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleNewUser(tt.args.cc, &tt.args.t)
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleListUsers(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "when user does not have required permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
- },
- Server: &Server{
- //Accounts: map[string]*Account{},
- },
- },
- t: NewTransaction(
- TranNewUser, [2]byte{0, 1},
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to view accounts.")),
- },
- },
- },
- },
- {
- name: "when user has required permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessOpenUser)
- return bits
- }(),
- },
- Server: &Server{
- AccountManager: func() *MockAccountManager {
- m := MockAccountManager{}
- m.On("List").Return([]Account{
- {
- Name: "guest",
- Login: "guest",
- Password: "zz",
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- },
- })
- return &m
- }(),
- },
- },
- t: NewTransaction(
- TranGetClientInfoText, [2]byte{0, 1},
- NewField(FieldUserID, []byte{0, 1}),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- Fields: []Field{
- NewField(FieldData, []byte{
- 0x00, 0x04, 0x00, 0x66, 0x00, 0x05, 0x67, 0x75, 0x65, 0x73, 0x74, 0x00, 0x69, 0x00, 0x05, 0x98,
- 0x8a, 0x9a, 0x8c, 0x8b, 0x00, 0x6e, 0x00, 0x08, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
- 0x00, 0x6a, 0x00, 0x01, 0x78,
- }),
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleListUsers(tt.args.cc, &tt.args.t)
-
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleDownloadFile(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "when user does not have required permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
- },
- Server: &Server{},
- },
- t: NewTransaction(TranDownloadFile, [2]byte{0, 1}),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to download files.")),
- },
- },
- },
- },
- {
- name: "with a valid file",
- args: args{
- cc: &ClientConn{
- ClientFileTransferMgr: NewClientFileTransferMgr(),
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessDownloadFile)
- return bits
- }(),
- },
- Server: &Server{
- FS: &OSFileStore{},
- FileTransferMgr: NewMemFileTransferMgr(),
- Config: Config{
- FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(),
- },
- },
- },
- t: NewTransaction(
- TranDownloadFile,
- [2]byte{0, 1},
- NewField(FieldFileName, []byte("testfile.txt")),
- NewField(FieldFilePath, []byte{0x0, 0x00}),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- Fields: []Field{
- NewField(FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}),
- NewField(FieldWaitingCount, []byte{0x00, 0x00}),
- NewField(FieldTransferSize, []byte{0x00, 0x00, 0x00, 0xa5}),
- NewField(FieldFileSize, []byte{0x00, 0x00, 0x00, 0x17}),
- },
- },
- },
- },
- {
- name: "when client requests to resume 1k test file at offset 256",
- args: args{
- cc: &ClientConn{
- ClientFileTransferMgr: NewClientFileTransferMgr(),
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessDownloadFile)
- return bits
- }(),
- },
- Server: &Server{
- FS: &OSFileStore{},
-
- // FS: func() *MockFileStore {
- // path, _ := os.Getwd()
- // testFile, err := os.Open(path + "/test/config/Files/testfile-1k")
- // if err != nil {
- // panic(err)
- // }
- //
- // mfi := &MockFileInfo{}
- // mfi.On("Mode").Return(fs.FileMode(0))
- // mfs := &MockFileStore{}
- // mfs.On("Stat", "/fakeRoot/Files/testfile.txt").Return(mfi, nil)
- // mfs.On("Open", "/fakeRoot/Files/testfile.txt").Return(testFile, nil)
- // mfs.On("Stat", "/fakeRoot/Files/.info_testfile.txt").Return(nil, errors.New("no"))
- // mfs.On("Stat", "/fakeRoot/Files/.rsrc_testfile.txt").Return(nil, errors.New("no"))
- //
- // return mfs
- // }(),
- FileTransferMgr: NewMemFileTransferMgr(),
- Config: Config{
- FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(),
- },
- //Accounts: map[string]*Account{},
- },
- },
- t: NewTransaction(
- TranDownloadFile,
- [2]byte{0, 1},
- NewField(FieldFileName, []byte("testfile-1k")),
- NewField(FieldFilePath, []byte{0x00, 0x00}),
- NewField(
- FieldFileResumeData,
- func() []byte {
- frd := FileResumeData{
- ForkCount: [2]byte{0, 2},
- ForkInfoList: []ForkInfoList{
- {
- Fork: [4]byte{0x44, 0x41, 0x54, 0x41}, // "DATA"
- DataSize: [4]byte{0, 0, 0x01, 0x00}, // request offset 256
- },
- {
- Fork: [4]byte{0x4d, 0x41, 0x43, 0x52}, // "MACR"
- DataSize: [4]byte{0, 0, 0, 0},
- },
- },
- }
- b, _ := frd.BinaryMarshal()
- return b
- }(),
- ),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- Fields: []Field{
- NewField(FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}),
- NewField(FieldWaitingCount, []byte{0x00, 0x00}),
- NewField(FieldTransferSize, []byte{0x00, 0x00, 0x03, 0x8d}),
- NewField(FieldFileSize, []byte{0x00, 0x00, 0x03, 0x00}),
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleDownloadFile(tt.args.cc, &tt.args.t)
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleUpdateUser(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "when action is create user without required permission",
- args: args{
- cc: &ClientConn{
- logger: NewTestLogger(),
- Server: &Server{
- AccountManager: func() *MockAccountManager {
- m := MockAccountManager{}
- m.On("Get", "bbb").Return((*Account)(nil))
- return &m
- }(),
- Logger: NewTestLogger(),
- },
- Account: &Account{
- Access: accessBitmap{},
- },
- },
- t: NewTransaction(
- TranUpdateUser,
- [2]byte{0, 0},
- NewField(FieldData, []byte{
- 0x00, 0x04, // field count
-
- 0x00, 0x69, // FieldUserLogin = 105
- 0x00, 0x03,
- 0x9d, 0x9d, 0x9d,
-
- 0x00, 0x6a, // FieldUserPassword = 106
- 0x00, 0x03,
- 0x9c, 0x9c, 0x9c,
-
- 0x00, 0x66, // FieldUserName = 102
- 0x00, 0x03,
- 0x61, 0x61, 0x61,
-
- 0x00, 0x6e, // FieldUserAccess = 110
- 0x00, 0x08,
- 0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00,
- }),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to create new accounts.")),
- },
- },
- },
- },
- {
- name: "when action is modify user without required permission",
- args: args{
- cc: &ClientConn{
- logger: NewTestLogger(),
- Server: &Server{
- Logger: NewTestLogger(),
- AccountManager: func() *MockAccountManager {
- m := MockAccountManager{}
- m.On("Get", "bbb").Return(&Account{})
- return &m
- }(),
- },
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
- },
- },
- t: NewTransaction(
- TranUpdateUser,
- [2]byte{0, 0},
- NewField(FieldData, []byte{
- 0x00, 0x04, // field count
-
- 0x00, 0x69, // FieldUserLogin = 105
- 0x00, 0x03,
- 0x9d, 0x9d, 0x9d,
-
- 0x00, 0x6a, // FieldUserPassword = 106
- 0x00, 0x03,
- 0x9c, 0x9c, 0x9c,
-
- 0x00, 0x66, // FieldUserName = 102
- 0x00, 0x03,
- 0x61, 0x61, 0x61,
-
- 0x00, 0x6e, // FieldUserAccess = 110
- 0x00, 0x08,
- 0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00,
- }),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to modify accounts.")),
- },
- },
- },
- },
- {
- name: "when action is delete user without required permission",
- args: args{
- cc: &ClientConn{
- logger: NewTestLogger(),
- Server: &Server{},
- Account: &Account{
- Access: accessBitmap{},
- },
- },
- t: NewTransaction(
- TranUpdateUser,
- [2]byte{0, 0},
- NewField(FieldData, []byte{
- 0x00, 0x01,
- 0x00, 0x65,
- 0x00, 0x03,
- 0x88, 0x9e, 0x8b,
- }),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to delete accounts.")),
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleUpdateUser(tt.args.cc, &tt.args.t)
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleDelNewsArt(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "without required permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
- },
- },
- t: NewTransaction(
- TranDelNewsArt,
- [2]byte{0, 0},
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to delete news articles.")),
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleDelNewsArt(tt.args.cc, &tt.args.t)
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleDisconnectUser(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "without required permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
- },
- },
- t: NewTransaction(
- TranDelNewsArt,
- [2]byte{0, 0},
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to disconnect users.")),
- },
- },
- },
- },
- {
- name: "when target user has 'cannot be disconnected' priv",
- args: args{
- cc: &ClientConn{
- Server: &Server{
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("Get", ClientID{0x0, 0x1}).Return(&ClientConn{
- Account: &Account{
- Login: "unnamed",
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessCannotBeDiscon)
- return bits
- }(),
- },
- },
- )
- return &m
- }(),
- },
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessDisconUser)
- return bits
- }(),
- },
- },
- t: NewTransaction(
- TranDelNewsArt,
- [2]byte{0, 0},
- NewField(FieldUserID, []byte{0, 1}),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("unnamed is not allowed to be disconnected.")),
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleDisconnectUser(tt.args.cc, &tt.args.t)
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleSendInstantMsg(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "without required permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
- },
- },
- t: NewTransaction(
- TranDelNewsArt,
- [2]byte{0, 0},
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to send private messages.")),
- },
- },
- },
- },
- {
- name: "when client 1 sends a message to client 2",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessSendPrivMsg)
- return bits
- }(),
- },
- ID: [2]byte{0, 1},
- UserName: []byte("User1"),
- Server: &Server{
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("Get", ClientID{0x0, 0x2}).Return(&ClientConn{
- AutoReply: []byte(nil),
- Flags: [2]byte{0, 0},
- },
- )
- return &m
- }(),
- },
- },
- t: NewTransaction(
- TranSendInstantMsg,
- [2]byte{0, 1},
- NewField(FieldData, []byte("hai")),
- NewField(FieldUserID, []byte{0, 2}),
- ),
- },
- wantRes: []Transaction{
- NewTransaction(
- TranServerMsg,
- [2]byte{0, 2},
- NewField(FieldData, []byte("hai")),
- NewField(FieldUserName, []byte("User1")),
- NewField(FieldUserID, []byte{0, 1}),
- NewField(FieldOptions, []byte{0, 1}),
- ),
- {
- clientID: [2]byte{0, 1},
- IsReply: 0x01,
- Fields: []Field(nil),
- },
- },
- },
- {
- name: "when client 2 has autoreply enabled",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessSendPrivMsg)
- return bits
- }(),
- },
- ID: [2]byte{0, 1},
- UserName: []byte("User1"),
- Server: &Server{
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("Get", ClientID{0x0, 0x2}).Return(&ClientConn{
- Flags: [2]byte{0, 0},
- ID: [2]byte{0, 2},
- UserName: []byte("User2"),
- AutoReply: []byte("autohai"),
- })
- return &m
- }(),
- },
- },
- t: NewTransaction(
- TranSendInstantMsg,
- [2]byte{0, 1},
- NewField(FieldData, []byte("hai")),
- NewField(FieldUserID, []byte{0, 2}),
- ),
- },
- wantRes: []Transaction{
- NewTransaction(
- TranServerMsg,
- [2]byte{0, 2},
- NewField(FieldData, []byte("hai")),
- NewField(FieldUserName, []byte("User1")),
- NewField(FieldUserID, []byte{0, 1}),
- NewField(FieldOptions, []byte{0, 1}),
- ),
- NewTransaction(
- TranServerMsg,
- [2]byte{0, 1},
- NewField(FieldData, []byte("autohai")),
- NewField(FieldUserName, []byte("User2")),
- NewField(FieldUserID, []byte{0, 2}),
- NewField(FieldOptions, []byte{0, 1}),
- ),
- {
- clientID: [2]byte{0, 1},
- IsReply: 0x01,
- Fields: []Field(nil),
- },
- },
- },
- {
- name: "when client 2 has refuse private messages enabled",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessSendPrivMsg)
- return bits
- }(),
- },
- ID: [2]byte{0, 1},
- UserName: []byte("User1"),
- Server: &Server{
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("Get", ClientID{0x0, 0x2}).Return(&ClientConn{
- Flags: [2]byte{255, 255},
- ID: [2]byte{0, 2},
- UserName: []byte("User2"),
- },
- )
- return &m
- }(),
- },
- },
- t: NewTransaction(
- TranSendInstantMsg,
- [2]byte{0, 1},
- NewField(FieldData, []byte("hai")),
- NewField(FieldUserID, []byte{0, 2}),
- ),
- },
- wantRes: []Transaction{
- NewTransaction(
- TranServerMsg,
- [2]byte{0, 1},
- NewField(FieldData, []byte("User2 does not accept private messages.")),
- NewField(FieldUserName, []byte("User2")),
- NewField(FieldUserID, []byte{0, 2}),
- NewField(FieldOptions, []byte{0, 2}),
- ),
- {
- clientID: [2]byte{0, 1},
- IsReply: 0x01,
- Fields: []Field(nil),
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleSendInstantMsg(tt.args.cc, &tt.args.t)
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleDeleteFile(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "when user does not have required permission to delete a folder",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
- },
- Server: &Server{
- Config: Config{
- FileRoot: func() string {
- return "/fakeRoot/Files"
- }(),
- },
- FS: func() *MockFileStore {
- mfi := &MockFileInfo{}
- mfi.On("Mode").Return(fs.FileMode(0))
- mfi.On("Size").Return(int64(100))
- mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout))
- mfi.On("IsDir").Return(false)
- mfi.On("Name").Return("testfile")
-
- mfs := &MockFileStore{}
- mfs.On("Stat", "/fakeRoot/Files/aaa/testfile").Return(mfi, nil)
- mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfile").Return(nil, errors.New("err"))
- mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil, errors.New("err"))
-
- return mfs
- }(),
- //Accounts: map[string]*Account{},
- },
- },
- t: NewTransaction(
- TranDeleteFile, [2]byte{0, 1},
- NewField(FieldFileName, []byte("testfile")),
- NewField(FieldFilePath, []byte{
- 0x00, 0x01,
- 0x00, 0x00,
- 0x03,
- 0x61, 0x61, 0x61,
- }),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to delete files.")),
- },
- },
- },
- },
- {
- name: "deletes all associated metadata files",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessDeleteFile)
- return bits
- }(),
- },
- Server: &Server{
- Config: Config{
- FileRoot: func() string {
- return "/fakeRoot/Files"
- }(),
- },
- FS: func() *MockFileStore {
- mfi := &MockFileInfo{}
- mfi.On("Mode").Return(fs.FileMode(0))
- mfi.On("Size").Return(int64(100))
- mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout))
- mfi.On("IsDir").Return(false)
- mfi.On("Name").Return("testfile")
-
- mfs := &MockFileStore{}
- mfs.On("Stat", "/fakeRoot/Files/aaa/testfile").Return(mfi, nil)
- mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfile").Return(nil, errors.New("err"))
- mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil, errors.New("err"))
-
- mfs.On("RemoveAll", "/fakeRoot/Files/aaa/testfile").Return(nil)
- mfs.On("Remove", "/fakeRoot/Files/aaa/testfile.incomplete").Return(nil)
- mfs.On("Remove", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil)
- mfs.On("Remove", "/fakeRoot/Files/aaa/.info_testfile").Return(nil)
-
- return mfs
- }(),
- //Accounts: map[string]*Account{},
- },
- },
- t: NewTransaction(
- TranDeleteFile, [2]byte{0, 1},
- NewField(FieldFileName, []byte("testfile")),
- NewField(FieldFilePath, []byte{
- 0x00, 0x01,
- 0x00, 0x00,
- 0x03,
- 0x61, 0x61, 0x61,
- }),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- Fields: []Field(nil),
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleDeleteFile(tt.args.cc, &tt.args.t)
- tranAssertEqual(t, tt.wantRes, gotRes)
-
- tt.args.cc.Server.FS.(*MockFileStore).AssertExpectations(t)
- })
- }
-}
-
-func TestHandleGetFileNameList(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "when FieldFilePath is a drop box, but user does not have AccessViewDropBoxes ",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
- },
- Server: &Server{
-
- Config: Config{
- FileRoot: func() string {
- path, _ := os.Getwd()
- return filepath.Join(path, "/test/config/Files/getFileNameListTestDir")
- }(),
- },
- },
- },
- t: NewTransaction(
- TranGetFileNameList, [2]byte{0, 1},
- NewField(FieldFilePath, []byte{
- 0x00, 0x01,
- 0x00, 0x00,
- 0x08,
- 0x64, 0x72, 0x6f, 0x70, 0x20, 0x62, 0x6f, 0x78, // "drop box"
- }),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to view drop boxes.")),
- },
- },
- },
- },
- {
- name: "with file root",
- args: args{
- cc: &ClientConn{
- Server: &Server{
- Config: Config{
- FileRoot: func() string {
- path, _ := os.Getwd()
- return filepath.Join(path, "/test/config/Files/getFileNameListTestDir")
- }(),
- },
- },
- },
- t: NewTransaction(
- TranGetFileNameList, [2]byte{0, 1},
- NewField(FieldFilePath, []byte{
- 0x00, 0x00,
- 0x00, 0x00,
- }),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- Fields: []Field{
- NewField(
- FieldFileNameWithInfo,
- func() []byte {
- fnwi := FileNameWithInfo{
- fileNameWithInfoHeader: fileNameWithInfoHeader{
- Type: [4]byte{0x54, 0x45, 0x58, 0x54},
- Creator: [4]byte{0x54, 0x54, 0x58, 0x54},
- FileSize: [4]byte{0, 0, 0x04, 0},
- RSVD: [4]byte{},
- NameScript: [2]byte{},
- NameSize: [2]byte{0, 0x0b},
- },
- Name: []byte("testfile-1k"),
- }
- b, _ := io.ReadAll(&fnwi)
- return b
- }(),
- ),
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleGetFileNameList(tt.args.cc, &tt.args.t)
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleGetClientInfoText(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "when user does not have required permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
- },
- Server: &Server{
- //Accounts: map[string]*Account{},
- },
- },
- t: NewTransaction(
- TranGetClientInfoText, [2]byte{0, 1},
- NewField(FieldUserID, []byte{0, 1}),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to get client info.")),
- },
- },
- },
- },
- {
- name: "with a valid user",
- args: args{
- cc: &ClientConn{
- UserName: []byte("Testy McTest"),
- RemoteAddr: "1.2.3.4:12345",
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessGetClientInfo)
- return bits
- }(),
- Name: "test",
- Login: "test",
- },
- Server: &Server{
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("Get", ClientID{0x0, 0x1}).Return(&ClientConn{
- UserName: []byte("Testy McTest"),
- RemoteAddr: "1.2.3.4:12345",
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessGetClientInfo)
- return bits
- }(),
- Name: "test",
- Login: "test",
- },
- },
- )
- return &m
- }(),
- },
- ClientFileTransferMgr: ClientFileTransferMgr{},
- },
- t: NewTransaction(
- TranGetClientInfoText, [2]byte{0, 1},
- NewField(FieldUserID, []byte{0, 1}),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- Fields: []Field{
- NewField(FieldData, []byte(
- strings.ReplaceAll(`Nickname: Testy McTest
-Name: test
-Account: test
-Address: 1.2.3.4:12345
-
--------- File Downloads ---------
-
-None.
-
-------- Folder Downloads --------
-
-None.
-
---------- File Uploads ----------
-
-None.
-
--------- Folder Uploads ---------
-
-None.
-
-------- Waiting Downloads -------
-
-None.
-
-`, "\n", "\r")),
- ),
- NewField(FieldUserName, []byte("Testy McTest")),
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleGetClientInfoText(tt.args.cc, &tt.args.t)
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleTranAgreed(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "normal request flow",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessDisconUser)
- bits.Set(AccessAnyName)
- return bits
- }()},
- Icon: []byte{0, 1},
- Flags: [2]byte{0, 1},
- Version: []byte{0, 1},
- ID: [2]byte{0, 1},
- logger: NewTestLogger(),
- Server: &Server{
- Config: Config{
- BannerFile: "banner.jpg",
- },
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("List").Return([]*ClientConn{
- //{
- // ID: [2]byte{0, 2},
- // UserName: []byte("UserB"),
- //},
- },
- )
- return &m
- }(),
- },
- },
- t: NewTransaction(
- TranAgreed, [2]byte{},
- NewField(FieldUserName, []byte("username")),
- NewField(FieldUserIconID, []byte{0, 1}),
- NewField(FieldOptions, []byte{0, 0}),
- ),
- },
- wantRes: []Transaction{
- {
- clientID: [2]byte{0, 1},
- Type: [2]byte{0, 0x7a},
- Fields: []Field{
- NewField(FieldBannerType, []byte("JPEG")),
- },
- },
- {
- clientID: [2]byte{0, 1},
- IsReply: 0x01,
- Fields: []Field{},
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleTranAgreed(tt.args.cc, &tt.args.t)
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleSetClientUserInfo(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "when client does not have AccessAnyName",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
- },
- ID: [2]byte{0, 1},
- UserName: []byte("Guest"),
- Flags: [2]byte{0, 1},
- Server: &Server{
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("List").Return([]*ClientConn{
- {
- ID: [2]byte{0, 1},
- },
- })
- return &m
- }(),
- },
- },
- t: NewTransaction(
- TranSetClientUserInfo, [2]byte{},
- NewField(FieldUserIconID, []byte{0, 1}),
- NewField(FieldUserName, []byte("NOPE")),
- ),
- },
- wantRes: []Transaction{
- {
- clientID: [2]byte{0, 1},
- Type: [2]byte{0x01, 0x2d},
- Fields: []Field{
- NewField(FieldUserID, []byte{0, 1}),
- NewField(FieldUserIconID, []byte{0, 1}),
- NewField(FieldUserFlags, []byte{0, 1}),
- NewField(FieldUserName, []byte("Guest"))},
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleSetClientUserInfo(tt.args.cc, &tt.args.t)
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleDelNewsItem(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "when user does not have permission to delete a news category",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: accessBitmap{},
- },
- ID: [2]byte{0, 1},
- Server: &Server{
- ThreadedNewsMgr: func() *mockThreadNewsMgr {
- m := mockThreadNewsMgr{}
- m.On("NewsItem", []string{"test"}).Return(NewsCategoryListData15{
- Type: NewsCategory,
- })
- return &m
- }(),
- },
- },
- t: NewTransaction(
- TranDelNewsItem, [2]byte{},
- NewField(FieldNewsPath,
- []byte{
- 0, 1,
- 0, 0,
- 4,
- 0x74, 0x65, 0x73, 0x74,
- },
- ),
- ),
- },
- wantRes: []Transaction{
- {
- clientID: [2]byte{0, 1},
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to delete news categories.")),
- },
- },
- },
- },
- {
- name: "when user does not have permission to delete a news folder",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: accessBitmap{},
- },
- ID: [2]byte{0, 1},
- Server: &Server{
- ThreadedNewsMgr: func() *mockThreadNewsMgr {
- m := mockThreadNewsMgr{}
- m.On("NewsItem", []string{"test"}).Return(NewsCategoryListData15{
- Type: NewsBundle,
- })
- return &m
- }(),
- },
- },
- t: NewTransaction(
- TranDelNewsItem, [2]byte{},
- NewField(FieldNewsPath,
- []byte{
- 0, 1,
- 0, 0,
- 4,
- 0x74, 0x65, 0x73, 0x74,
- },
- ),
- ),
- },
- wantRes: []Transaction{
- {
- clientID: [2]byte{0, 1},
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to delete news folders.")),
- },
- },
- },
- },
- {
- name: "when user deletes a news folder",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessNewsDeleteFldr)
- return bits
- }(),
- },
- ID: [2]byte{0, 1},
- Server: &Server{
- ThreadedNewsMgr: func() *mockThreadNewsMgr {
- m := mockThreadNewsMgr{}
- m.On("NewsItem", []string{"test"}).Return(NewsCategoryListData15{Type: NewsBundle})
- m.On("DeleteNewsItem", []string{"test"}).Return(nil)
- return &m
- }(),
- },
- },
- t: NewTransaction(
- TranDelNewsItem, [2]byte{},
- NewField(FieldNewsPath,
- []byte{
- 0, 1,
- 0, 0,
- 4,
- 0x74, 0x65, 0x73, 0x74,
- },
- ),
- ),
- },
- wantRes: []Transaction{
- {
- clientID: [2]byte{0, 1},
- IsReply: 0x01,
- Fields: []Field{},
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleDelNewsItem(tt.args.cc, &tt.args.t)
-
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleTranOldPostNews(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "when user does not have required permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: accessBitmap{},
- },
- },
- t: NewTransaction(
- TranOldPostNews, [2]byte{0, 1},
- NewField(FieldData, []byte("hai")),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to post news.")),
- },
- },
- },
- },
- {
- name: "when user posts news update",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessNewsPostArt)
- return bits
- }(),
- },
- Server: &Server{
- Config: Config{
- NewsDateFormat: "",
- },
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("List").Return([]*ClientConn{})
- return &m
- }(),
- MessageBoard: func() *mockReadWriteSeeker {
- m := mockReadWriteSeeker{}
- m.On("Seek", int64(0), 0).Return(int64(0), nil)
- m.On("Read", mock.AnythingOfType("[]uint8")).Run(func(args mock.Arguments) {
- arg := args.Get(0).([]uint8)
- copy(arg, "TEST")
- }).Return(4, io.EOF)
- m.On("Write", mock.AnythingOfType("[]uint8")).Return(3, nil)
- return &m
- }(),
- },
- },
- t: NewTransaction(
- TranOldPostNews, [2]byte{0, 1},
- NewField(FieldData, []byte("hai")),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleTranOldPostNews(tt.args.cc, &tt.args.t)
-
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleInviteNewChat(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "when user does not have required permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
- },
- },
- t: NewTransaction(TranInviteNewChat, [2]byte{0, 1}),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to request private chat.")),
- },
- },
- },
- },
- {
- name: "when userA invites userB to new private chat",
- args: args{
- cc: &ClientConn{
- ID: [2]byte{0, 1},
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessOpenChat)
- return bits
- }(),
- },
- UserName: []byte("UserA"),
- Icon: []byte{0, 1},
- Flags: [2]byte{0, 0},
- Server: &Server{
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("Get", ClientID{0x0, 0x2}).Return(&ClientConn{
- ID: [2]byte{0, 2},
- UserName: []byte("UserB"),
- })
- return &m
- }(),
- ChatMgr: func() *MockChatManager {
- m := MockChatManager{}
- m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(ChatID{0x52, 0xfd, 0xfc, 0x07})
- return &m
- }(),
- },
- },
- t: NewTransaction(
- TranInviteNewChat, [2]byte{0, 1},
- NewField(FieldUserID, []byte{0, 2}),
- ),
- },
- wantRes: []Transaction{
- {
- clientID: [2]byte{0, 2},
- Type: [2]byte{0, 0x71},
- Fields: []Field{
- NewField(FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}),
- NewField(FieldUserName, []byte("UserA")),
- NewField(FieldUserID, []byte{0, 1}),
- },
- },
- {
- clientID: [2]byte{0, 1},
- IsReply: 0x01,
- Fields: []Field{
- NewField(FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}),
- NewField(FieldUserName, []byte("UserA")),
- NewField(FieldUserID, []byte{0, 1}),
- NewField(FieldUserIconID, []byte{0, 1}),
- NewField(FieldUserFlags, []byte{0, 0}),
- },
- },
- },
- },
- {
- name: "when userA invites userB to new private chat, but UserB has refuse private chat enabled",
- args: args{
- cc: &ClientConn{
- ID: [2]byte{0, 1},
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessOpenChat)
- return bits
- }(),
- },
- UserName: []byte("UserA"),
- Icon: []byte{0, 1},
- Flags: [2]byte{0, 0},
- Server: &Server{
- ClientMgr: func() *MockClientMgr {
- m := MockClientMgr{}
- m.On("Get", ClientID{0, 2}).Return(&ClientConn{
- ID: [2]byte{0, 2},
- Icon: []byte{0, 1},
- UserName: []byte("UserB"),
- Flags: [2]byte{255, 255},
- })
- return &m
- }(),
- ChatMgr: func() *MockChatManager {
- m := MockChatManager{}
- m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(ChatID{0x52, 0xfd, 0xfc, 0x07})
- return &m
- }(),
- },
- },
- t: NewTransaction(
- TranInviteNewChat, [2]byte{0, 1},
- NewField(FieldUserID, []byte{0, 2}),
- ),
- },
- wantRes: []Transaction{
- {
- clientID: [2]byte{0, 1},
- Type: [2]byte{0, 0x68},
- Fields: []Field{
- NewField(FieldData, []byte("UserB does not accept private chats.")),
- NewField(FieldUserName, []byte("UserB")),
- NewField(FieldUserID, []byte{0, 2}),
- NewField(FieldOptions, []byte{0, 2}),
- },
- },
- {
- clientID: [2]byte{0, 1},
- IsReply: 0x01,
- Fields: []Field{
- NewField(FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}),
- NewField(FieldUserName, []byte("UserA")),
- NewField(FieldUserID, []byte{0, 1}),
- NewField(FieldUserIconID, []byte{0, 1}),
- NewField(FieldUserFlags, []byte{0, 0}),
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
-
- gotRes := HandleInviteNewChat(tt.args.cc, &tt.args.t)
-
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleGetNewsArtData(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "when user does not have required permission",
- args: args{
- cc: &ClientConn{Account: &Account{}},
- t: NewTransaction(
- TranGetNewsArtData, [2]byte{0, 1},
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to read news.")),
- },
- },
- },
- },
- {
- name: "when user has required permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessNewsReadArt)
- return bits
- }(),
- },
- Server: &Server{
- ThreadedNewsMgr: func() *mockThreadNewsMgr {
- m := mockThreadNewsMgr{}
- m.On("GetArticle", []string{"Example Category"}, uint32(1)).Return(&NewsArtData{
- Title: "title",
- Poster: "poster",
- Date: [8]byte{},
- PrevArt: [4]byte{0, 0, 0, 1},
- NextArt: [4]byte{0, 0, 0, 2},
- ParentArt: [4]byte{0, 0, 0, 3},
- FirstChildArt: [4]byte{0, 0, 0, 4},
- DataFlav: []byte("text/plain"),
- Data: "article data",
- })
- return &m
- }(),
- },
- },
- t: NewTransaction(
- TranGetNewsArtData, [2]byte{0, 1},
- NewField(FieldNewsPath, []byte{
- // Example Category
- 0x00, 0x01, 0x00, 0x00, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79,
- }),
- NewField(FieldNewsArtID, []byte{0, 1}),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 1,
- Fields: []Field{
- NewField(FieldNewsArtTitle, []byte("title")),
- NewField(FieldNewsArtPoster, []byte("poster")),
- NewField(FieldNewsArtDate, []byte{0, 0, 0, 0, 0, 0, 0, 0}),
- NewField(FieldNewsArtPrevArt, []byte{0, 0, 0, 1}),
- NewField(FieldNewsArtNextArt, []byte{0, 0, 0, 2}),
- NewField(FieldNewsArtParentArt, []byte{0, 0, 0, 3}),
- NewField(FieldNewsArt1stChildArt, []byte{0, 0, 0, 4}),
- NewField(FieldNewsArtDataFlav, []byte("text/plain")),
- NewField(FieldNewsArtData, []byte("article data")),
- },
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleGetNewsArtData(tt.args.cc, &tt.args.t)
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleGetNewsArtNameList(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "when user does not have required permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
- },
- Server: &Server{
- //Accounts: map[string]*Account{},
- },
- },
- t: NewTransaction(
- TranGetNewsArtNameList, [2]byte{0, 1},
- ),
- },
- wantRes: []Transaction{
- {
- Flags: 0x00,
- IsReply: 0x01,
- Type: [2]byte{0, 0},
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to read news.")),
- },
- },
- },
- },
- //{
- // name: "when user has required access",
- // args: args{
- // cc: &ClientConn{
- // Account: &Account{
- // Access: func() accessBitmap {
- // var bits accessBitmap
- // bits.Set(AccessNewsReadArt)
- // return bits
- // }(),
- // },
- // Server: &Server{
- // ThreadedNewsMgr: func() *mockThreadNewsMgr {
- // m := mockThreadNewsMgr{}
- // m.On("ListArticles", []string{"Example Category"}).Return(NewsArtListData{
- // Name: []byte("testTitle"),
- // NewsArtList: []byte{},
- // })
- // return &m
- // }(),
- // },
- // },
- // t: NewTransaction(
- // TranGetNewsArtNameList,
- // [2]byte{0, 1},
- // // 00000000 00 01 00 00 10 45 78 61 6d 70 6c 65 20 43 61 74 |.....Example Cat|
- // // 00000010 65 67 6f 72 79 |egory|
- // NewField(FieldNewsPath, []byte{
- // 0x00, 0x01, 0x00, 0x00, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79,
- // }),
- // ),
- // },
- // wantRes: []Transaction{
- // {
- // IsReply: 0x01,
- // Fields: []Field{
- // NewField(FieldNewsArtListData, []byte{
- // 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
- // 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
- // 0x09, 0x74, 0x65, 0x73, 0x74, 0x54, 0x69, 0x74, 0x6c, 0x65, 0x0a, 0x74, 0x65, 0x73, 0x74, 0x50,
- // 0x6f, 0x73, 0x74, 0x65, 0x72, 0x0a, 0x74, 0x65, 0x78, 0x74, 0x2f, 0x70, 0x6c, 0x61, 0x69, 0x6e,
- // 0x00, 0x08,
- // },
- // ),
- // },
- // },
- // },
- //},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleGetNewsArtNameList(tt.args.cc, &tt.args.t)
-
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleNewNewsFldr(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "when user does not have required permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
- },
- Server: &Server{
- //Accounts: map[string]*Account{},
- },
- },
- t: NewTransaction(
- TranGetNewsArtNameList, [2]byte{0, 1},
- ),
- },
- wantRes: []Transaction{
- {
- Flags: 0x00,
- IsReply: 0x01,
- Type: [2]byte{0, 0},
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to create news folders.")),
- },
- },
- },
- },
- {
- name: "with a valid request",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessNewsCreateFldr)
- return bits
- }(),
- },
- logger: NewTestLogger(),
- ID: [2]byte{0, 1},
- Server: &Server{
- ThreadedNewsMgr: func() *mockThreadNewsMgr {
- m := mockThreadNewsMgr{}
- m.On("CreateGrouping", []string{"test"}, "testFolder", NewsBundle).Return(nil)
- return &m
- }(),
- },
- },
- t: NewTransaction(
- TranGetNewsArtNameList, [2]byte{0, 1},
- NewField(FieldFileName, []byte("testFolder")),
- NewField(FieldNewsPath,
- []byte{
- 0, 1,
- 0, 0,
- 4,
- 0x74, 0x65, 0x73, 0x74,
- },
- ),
- ),
- },
- wantRes: []Transaction{
- {
- clientID: [2]byte{0, 1},
- IsReply: 0x01,
- Fields: []Field{},
- },
- },
- },
- //{
- // Name: "when there is an error writing the threaded news file",
- // args: args{
- // cc: &ClientConn{
- // Account: &Account{
- // Access: func() accessBitmap {
- // var bits accessBitmap
- // bits.Set(AccessNewsCreateFldr)
- // return bits
- // }(),
- // },
- // logger: NewTestLogger(),
- // Type: [2]byte{0, 1},
- // Server: &Server{
- // ConfigDir: "/fakeConfigRoot",
- // FS: func() *MockFileStore {
- // mfs := &MockFileStore{}
- // mfs.On("WriteFile", "/fakeConfigRoot/ThreadedNews.yaml", mock.Anything, mock.Anything).Return(os.ErrNotExist)
- // return mfs
- // }(),
- // ThreadedNews: &ThreadedNews{Categories: map[string]NewsCategoryListData15{
- // "test": {
- // Type: []byte{0, 2},
- // Count: nil,
- // NameSize: 0,
- // Name: "test",
- // SubCats: make(map[string]NewsCategoryListData15),
- // },
- // }},
- // },
- // },
- // t: NewTransaction(
- // TranGetNewsArtNameList, [2]byte{0, 1},
- // NewField(FieldFileName, []byte("testFolder")),
- // NewField(FieldNewsPath,
- // []byte{
- // 0, 1,
- // 0, 0,
- // 4,
- // 0x74, 0x65, 0x73, 0x74,
- // },
- // ),
- // ),
- // },
- // wantRes: []Transaction{
- // {
- // clientID: [2]byte{0, 1},
- // Flags: 0x00,
- // IsReply: 0x01,
- // Type: [2]byte{0, 0},
- // ErrorCode: [4]byte{0, 0, 0, 1},
- // Fields: []Field{
- // NewField(FieldError, []byte("Error creating news folder.")),
- // },
- // },
- // },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleNewNewsFldr(tt.args.cc, &tt.args.t)
-
- tranAssertEqual(t, tt.wantRes, gotRes)
- })
- }
-}
-
-func TestHandleDownloadBanner(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- // TODO: Add test cases.
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gotRes := HandleDownloadBanner(tt.args.cc, &tt.args.t)
-
- assert.Equalf(t, tt.wantRes, gotRes, "HandleDownloadBanner(%v, %v)", tt.args.cc, &tt.args.t)
- })
- }
-}
-
-func TestHandlePostNewsArt(t *testing.T) {
- type args struct {
- cc *ClientConn
- t Transaction
- }
- tests := []struct {
- name string
- args args
- wantRes []Transaction
- }{
- {
- name: "without required permission",
- args: args{
- cc: &ClientConn{
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
- },
- },
- t: NewTransaction(
- TranPostNewsArt,
- [2]byte{0, 0},
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
- Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to post news articles.")),
- },
- },
- },
- },
- {
- name: "with required permission",
- args: args{
- cc: &ClientConn{
- Server: &Server{
- ThreadedNewsMgr: func() *mockThreadNewsMgr {
- m := mockThreadNewsMgr{}
- m.On("PostArticle", []string{"www"}, uint32(0), mock.AnythingOfType("hotline.NewsArtData")).Return(nil)
- return &m
- }(),
- },
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(AccessNewsPostArt)
- return bits
- }(),
- },
- },
- t: NewTransaction(
- TranPostNewsArt,
- [2]byte{0, 0},
- NewField(FieldNewsPath, []byte{0x00, 0x01, 0x00, 0x00, 0x03, 0x77, 0x77, 0x77}),
- NewField(FieldNewsArtID, []byte{0x00, 0x00, 0x00, 0x00}),
- ),
- },
- wantRes: []Transaction{
- {
- IsReply: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 0},
- Fields: []Field{},
- },
- },
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- tranAssertEqual(t, tt.wantRes, HandlePostNewsArt(tt.args.cc, &tt.args.t))
- })
- }
-}
for _, tt := range tests {
t1.Run(tt.name, func(t1 *testing.T) {
t := &Transaction{
- clientID: tt.fields.clientID,
+ ClientID: tt.fields.clientID,
Flags: tt.fields.Flags,
IsReply: tt.fields.IsReply,
Type: tt.fields.Type,
Data: []byte("hai"),
},
},
- clientID: [2]byte{},
+ ClientID: [2]byte{},
readOffset: 0,
},
},
}
assert.Equalf(t1, tt.wantN, gotN, "Write(%v)", tt.args.p)
- tranAssertEqual(t1, []Transaction{tt.wantTransaction}, []Transaction{*t})
+ TranAssertEqual(t1, []Transaction{tt.wantTransaction}, []Transaction{*t})
})
}
}
FlatFileHeader: FlatFileHeader{
Format: [4]byte{0x46, 0x49, 0x4c, 0x50}, // "FILP"
Version: [2]byte{0, 1},
- RSVD: [16]byte{},
ForkCount: [2]byte{0, 2},
},
FlatFileInformationForkHeader: FlatFileForkHeader{},
FlatFileInformationFork: NewFlatFileInformationFork("testfile.txt", [8]byte{}, "TEXT", "TEXT"),
FlatFileDataForkHeader: FlatFileForkHeader{
- ForkType: [4]byte{0x4d, 0x41, 0x43, 0x52}, // DATA
- CompressionType: [4]byte{0, 0, 0, 0},
- RSVD: [4]byte{0, 0, 0, 0},
- DataSize: [4]byte{0x00, 0x00, 0x00, 0x03},
+ ForkType: [4]byte{0x4d, 0x41, 0x43, 0x52}, // DATA
+ DataSize: [4]byte{0x00, 0x00, 0x00, 0x03},
},
}
fakeFileData := []byte{1, 2, 3}
return 8 + namelen, nil
}
-// encodeString takes []byte s containing cleartext and rotates by 255 into obfuscated cleartext.
+// EncodeString takes []byte s containing cleartext and rotates by 255 into obfuscated cleartext.
// The Hotline protocol uses this format for sending passwords over network.
// Not secure, but hey, it was the 90s!
-func encodeString(clearText []byte) []byte {
+func EncodeString(clearText []byte) []byte {
obfuText := make([]byte, len(clearText))
for i := 0; i < len(clearText); i++ {
obfuText[i] = 255 - clearText[i]
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- if got := encodeString(tt.args.encodedString); !bytes.Equal(got, tt.want) {
+ if got := EncodeString(tt.args.encodedString); !bytes.Equal(got, tt.want) {
t.Errorf("NegatedUserString() = %x, want %x", got, tt.want)
}
})
--- /dev/null
+package mobius
+
+import (
+ "fmt"
+ "github.com/jhalter/mobius/hotline"
+ "github.com/stretchr/testify/mock"
+ "gopkg.in/yaml.v3"
+ "os"
+ "path"
+ "path/filepath"
+ "sync"
+)
+
+// loadFromYAMLFile loads data from a YAML file into the provided data structure.
+func loadFromYAMLFile(path string, data interface{}) error {
+ fh, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer fh.Close()
+
+ decoder := yaml.NewDecoder(fh)
+ return decoder.Decode(data)
+}
+
+type YAMLAccountManager struct {
+ accounts map[string]hotline.Account
+ accountDir string
+
+ mu sync.Mutex
+}
+
+func NewYAMLAccountManager(accountDir string) (*YAMLAccountManager, error) {
+ accountMgr := YAMLAccountManager{
+ accountDir: accountDir,
+ accounts: make(map[string]hotline.Account),
+ }
+
+ matches, err := filepath.Glob(filepath.Join(accountDir, "*.yaml"))
+ if err != nil {
+ return nil, err
+ }
+
+ if len(matches) == 0 {
+ return nil, fmt.Errorf("no accounts found in directory: %s", accountDir)
+ }
+
+ for _, file := range matches {
+ var account hotline.Account
+ if err = loadFromYAMLFile(file, &account); err != nil {
+ return nil, fmt.Errorf("error loading account %s: %w", file, err)
+ }
+
+ accountMgr.accounts[account.Login] = account
+ }
+
+ return &accountMgr, nil
+}
+
+func (am *YAMLAccountManager) Create(account hotline.Account) error {
+ am.mu.Lock()
+ defer am.mu.Unlock()
+
+ // Create account file, returning an error if one already exists.
+ file, err := os.OpenFile(
+ filepath.Join(am.accountDir, path.Join("/", account.Login+".yaml")),
+ os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644,
+ )
+ if err != nil {
+ return fmt.Errorf("create account file: %w", err)
+ }
+ defer file.Close()
+
+ b, err := yaml.Marshal(account)
+ if err != nil {
+ return fmt.Errorf("marshal account to YAML: %v", err)
+ }
+
+ _, err = file.Write(b)
+ if err != nil {
+ return fmt.Errorf("write account file: %w", err)
+ }
+
+ am.accounts[account.Login] = account
+
+ return nil
+}
+
+func (am *YAMLAccountManager) Update(account hotline.Account, newLogin string) error {
+ am.mu.Lock()
+ defer am.mu.Unlock()
+
+ // If the login has changed, rename the account file.
+ if account.Login != newLogin {
+ err := os.Rename(
+ filepath.Join(am.accountDir, path.Join("/", account.Login)+".yaml"),
+ filepath.Join(am.accountDir, path.Join("/", newLogin)+".yaml"),
+ )
+ if err != nil {
+ return fmt.Errorf("error renaming account file: %w", err)
+ }
+
+ account.Login = newLogin
+ am.accounts[newLogin] = account
+
+ delete(am.accounts, account.Login)
+ }
+
+ out, err := yaml.Marshal(&account)
+ if err != nil {
+ return err
+ }
+
+ if err := os.WriteFile(filepath.Join(am.accountDir, newLogin+".yaml"), out, 0644); err != nil {
+ return fmt.Errorf("error writing account file: %w", err)
+ }
+
+ am.accounts[account.Login] = account
+
+ return nil
+}
+
+func (am *YAMLAccountManager) Get(login string) *hotline.Account {
+ am.mu.Lock()
+ defer am.mu.Unlock()
+
+ account, ok := am.accounts[login]
+ if !ok {
+ return nil
+ }
+
+ return &account
+}
+
+func (am *YAMLAccountManager) List() []hotline.Account {
+ am.mu.Lock()
+ defer am.mu.Unlock()
+
+ var accounts []hotline.Account
+ for _, account := range am.accounts {
+ accounts = append(accounts, account)
+ }
+
+ return accounts
+}
+
+func (am *YAMLAccountManager) Delete(login string) error {
+ am.mu.Lock()
+ defer am.mu.Unlock()
+
+ err := os.Remove(filepath.Join(am.accountDir, path.Join("/", login+".yaml")))
+ if err != nil {
+ return fmt.Errorf("delete account file: %v", err)
+ }
+
+ delete(am.accounts, login)
+
+ return nil
+}
+
+type MockAccountManager struct {
+ mock.Mock
+}
+
+func (m *MockAccountManager) Create(account hotline.Account) error {
+ args := m.Called(account)
+
+ return args.Error(0)
+}
+
+func (m *MockAccountManager) Update(account hotline.Account, newLogin string) error {
+ args := m.Called(account, newLogin)
+
+ return args.Error(0)
+}
+
+func (m *MockAccountManager) Get(login string) *hotline.Account {
+ args := m.Called(login)
+
+ return args.Get(0).(*hotline.Account)
+}
+
+func (m *MockAccountManager) List() []hotline.Account {
+ args := m.Called()
+
+ return args.Get(0).([]hotline.Account)
+}
+
+func (m *MockAccountManager) Delete(login string) error {
+ args := m.Called(login)
+
+ return args.Error(0)
+}
--- /dev/null
+package mobius
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+)
+
+const agreementFile = "Agreement.txt"
+
+type Agreement struct {
+ data []byte
+ filePath string
+ lineEndings string
+
+ mu sync.RWMutex
+ readOffset int // Internal offset to track read progress
+}
+
+func NewAgreement(path, lineEndings string) (*Agreement, error) {
+ data, err := os.ReadFile(filepath.Join(path, agreementFile))
+ if err != nil {
+ return &Agreement{}, fmt.Errorf("read file: %w", err)
+ }
+
+ // Swap line breaks
+ agreement := strings.ReplaceAll(string(data), "\n", lineEndings)
+ agreement = strings.ReplaceAll(agreement, "\r\n", lineEndings)
+
+ return &Agreement{
+ data: []byte(agreement),
+ filePath: filepath.Join(path, agreementFile),
+ lineEndings: lineEndings,
+ }, nil
+}
+
+func (a *Agreement) Reload() error {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+
+ data, err := os.ReadFile(a.filePath)
+ if err != nil {
+ return fmt.Errorf("read file: %w", err)
+ }
+
+ // Swap line breaks
+ agreement := strings.ReplaceAll(string(data), "\n", a.lineEndings)
+ agreement = strings.ReplaceAll(agreement, "\r\n", a.lineEndings)
+
+ a.data = []byte(agreement)
+
+ return nil
+}
+
+// It returns the number of bytes read and any error encountered.
+func (a *Agreement) Read(p []byte) (int, error) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+
+ if a.readOffset >= len(a.data) {
+ return 0, io.EOF // All bytes have been read
+ }
+
+ n := copy(p, a.data[a.readOffset:])
+
+ a.readOffset += n
+
+ return n, nil
+}
+
+func (a *Agreement) Seek(offset int64, _ int) (int64, error) {
+ a.readOffset = int(offset)
+
+ return 0, nil
+}
package mobius
import (
+ "fmt"
"gopkg.in/yaml.v3"
"os"
"path/filepath"
}
err := bf.Load()
+ if err != nil {
+ return nil, fmt.Errorf("load ban file: %w", err)
+ }
- return bf, err
+ return bf, nil
}
func (bf *BanFile) Load() error {
bf.banList = make(map[string]*time.Time)
fh, err := os.Open(bf.filePath)
+ if os.IsNotExist(err) {
+ return nil
+ }
if err != nil {
- if os.IsNotExist(err) {
- return nil
- }
- return err
+ return fmt.Errorf("open file: %v", err)
}
defer fh.Close()
- decoder := yaml.NewDecoder(fh)
- err = decoder.Decode(&bf.banList)
+ err = yaml.NewDecoder(fh).Decode(&bf.banList)
if err != nil {
- return err
+ return fmt.Errorf("decode yaml: %v", err)
}
return nil
out, err := yaml.Marshal(bf.banList)
if err != nil {
- return err
+ return fmt.Errorf("marshal yaml: %v", err)
}
- return os.WriteFile(filepath.Join(bf.filePath), out, 0644)
+ err = os.WriteFile(filepath.Join(bf.filePath), out, 0644)
+ if err != nil {
+ return fmt.Errorf("write file: %v", err)
+ }
+
+ return nil
}
func (bf *BanFile) IsBanned(ip string) (bool, *time.Time) {
--- /dev/null
+package mobius
+
+import (
+ "fmt"
+ "github.com/stretchr/testify/assert"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+)
+
+func TestNewBanFile(t *testing.T) {
+ cwd, _ := os.Getwd()
+ str := "2024-06-29T11:34:43.245899-07:00"
+ testTime, _ := time.Parse(time.RFC3339Nano, str)
+
+ type args struct {
+ path string
+ }
+ tests := []struct {
+ name string
+ args args
+ want *BanFile
+ wantErr assert.ErrorAssertionFunc
+ }{
+ {
+ name: "Valid path with valid content",
+ args: args{path: filepath.Join(cwd, "test", "config", "Banlist.yaml")},
+ want: &BanFile{
+ filePath: filepath.Join(cwd, "test", "config", "Banlist.yaml"),
+ banList: map[string]*time.Time{"192.168.86.29": &testTime},
+ },
+ wantErr: assert.NoError,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := NewBanFile(tt.args.path)
+ if !tt.wantErr(t, err, fmt.Sprintf("NewBanFile(%v)", tt.args.path)) {
+ return
+ }
+ assert.Equalf(t, tt.want, got, "NewBanFile(%v)", tt.args.path)
+ })
+ }
+}
+
+// TestAdd tests the Add function.
+func TestAdd(t *testing.T) {
+ // Create a temporary directory.
+ tmpDir, err := os.MkdirTemp("", "banfile_test")
+ if err != nil {
+ t.Fatalf("Failed to create temp directory: %v", err)
+ }
+ defer os.RemoveAll(tmpDir) // Clean up the temporary directory.
+
+ // Path to the temporary ban file.
+ tmpFilePath := filepath.Join(tmpDir, "banfile.yaml")
+
+ // Initialize BanFile.
+ bf := &BanFile{
+ filePath: tmpFilePath,
+ banList: make(map[string]*time.Time),
+ }
+
+ // Define the test cases.
+ tests := []struct {
+ name string
+ ip string
+ until *time.Time
+ expect map[string]*time.Time
+ }{
+ {
+ name: "Add IP with no expiration",
+ ip: "192.168.1.1",
+ until: nil,
+ expect: map[string]*time.Time{
+ "192.168.1.1": nil,
+ },
+ },
+ {
+ name: "Add IP with expiration",
+ ip: "192.168.1.2",
+ until: func() *time.Time { t := time.Date(2024, 6, 29, 11, 34, 43, 245899000, time.UTC); return &t }(),
+ expect: map[string]*time.Time{
+ "192.168.1.1": nil,
+ "192.168.1.2": func() *time.Time { t := time.Date(2024, 6, 29, 11, 34, 43, 245899000, time.UTC); return &t }(),
+ },
+ },
+ }
+
+ // Run the test cases.
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := bf.Add(tt.ip, tt.until)
+ assert.NoError(t, err, "Add() error")
+
+ // Load the file to check its contents.
+ loadedBanFile := &BanFile{filePath: tmpFilePath}
+ err = loadedBanFile.Load()
+ assert.NoError(t, err, "Load() error")
+ assert.Equal(t, tt.expect, loadedBanFile.banList, "Ban list does not match")
+ })
+ }
+}
+
+func TestBanFile_IsBanned(t *testing.T) {
+ type fields struct {
+ banList map[string]*time.Time
+ Mutex sync.Mutex
+ }
+ type args struct {
+ ip string
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ want bool
+ want1 *time.Time
+ }{
+ {
+ name: "with permanent ban",
+ fields: fields{
+ banList: map[string]*time.Time{
+ "192.168.86.1": nil,
+ },
+ },
+ args: args{ip: "192.168.86.1"},
+ want: true,
+ want1: nil,
+ },
+ {
+ name: "with no ban",
+ fields: fields{
+ banList: map[string]*time.Time{},
+ },
+ args: args{ip: "192.168.86.1"},
+ want: false,
+ want1: nil,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ bf := &BanFile{
+ banList: tt.fields.banList,
+ Mutex: sync.Mutex{},
+ }
+ got, got1 := bf.IsBanned(tt.args.ip)
+ assert.Equalf(t, tt.want, got, "IsBanned(%v)", tt.args.ip)
+ assert.Equalf(t, tt.want1, got1, "IsBanned(%v)", tt.args.ip)
+ })
+ }
+}
package mobius
import (
+ "fmt"
"github.com/go-playground/validator/v10"
"github.com/jhalter/mobius/hotline"
"gopkg.in/yaml.v3"
- "log"
"os"
+ "path/filepath"
)
+var ConfigSearchOrder = []string{
+ "config",
+ "/usr/local/var/mobius/config",
+ "/opt/homebrew/var/mobius/config",
+}
+
func LoadConfig(path string) (*hotline.Config, error) {
var config hotline.Config
yamlFile, err := os.ReadFile(path)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("read file: %v", err)
}
- err = yaml.Unmarshal(yamlFile, &config)
- if err != nil {
- log.Fatalf("Unmarshal: %v", err)
+
+ if err := yaml.Unmarshal(yamlFile, &config); err != nil {
+ return nil, fmt.Errorf("unmarshal YAML: %v", err)
}
validate := validator.New()
- err = validate.Struct(config)
- if err != nil {
- return nil, err
+ if err = validate.Struct(config); err != nil {
+ return nil, fmt.Errorf("validate config: %v", err)
+ }
+
+ // If the FileRoot is an absolute path, use it, otherwise treat as a relative path to the config dir.
+ if !filepath.IsAbs(config.FileRoot) {
+ config.FileRoot = filepath.Join(path, "../", config.FileRoot)
}
return &config, nil
"io"
"os"
"slices"
+ "strings"
"sync"
)
type FlatNews struct {
- mu sync.Mutex
-
data []byte
filePath string
+ mu sync.Mutex
readOffset int // Internal offset to track read progress
}
func NewFlatNews(path string) (*FlatNews, error) {
- data, err := os.ReadFile(path)
- if err != nil {
- return &FlatNews{}, err
+ flatNews := &FlatNews{filePath: path}
+ if err := flatNews.Reload(); err != nil {
+ return nil, fmt.Errorf("reload: %w", err)
}
- return &FlatNews{
- data: data,
- filePath: path,
- }, nil
+ return flatNews, nil
}
func (f *FlatNews) Reload() error {
if err != nil {
return err
}
- f.data = data
+
+ // Swap line breaks
+ agreement := strings.ReplaceAll(string(data), "\n", "\r")
+ agreement = strings.ReplaceAll(agreement, "\r\n", "\r")
+
+ f.data = []byte(agreement)
return nil
}
--- /dev/null
+This is a server agreement. Say you agree.
\ No newline at end of file
--- /dev/null
+192.168.86.29: 2024-06-29T11:34:43.245899-07:00
\ No newline at end of file
--- /dev/null
+|9à¼dâÍÞ
\ No newline at end of file
--- /dev/null
+nothing to see here
\ No newline at end of file
--- /dev/null
+Hello, I'm a test file!
\ No newline at end of file
--- /dev/null
+From test (Dec31 15:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:43):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:43):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:2):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 13:59):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 13:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 10:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec08 14:39):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec08 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec08 7:59):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec08 7:59):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:43):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 10:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 10:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 10:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 10:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 9:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec05 17:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec03 10:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 17:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:17):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:56):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:17):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 13:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 13:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 13:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:3):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:3):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:3):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:45):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:45):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:45):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:28):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:19):\r\rTest News Post\r\r__________________________________________________________\rFrom (Nov30 11:42):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:17):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:8):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:5):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:2):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:2):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:0):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:45):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:59):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:35):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:32):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:8):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:8):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:7):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:5):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:28):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 10:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 10:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 16:6):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 16:6):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:45):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:43):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:7):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:7):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:6):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:6):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:5):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:4):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:4):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:4):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:3):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:2):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:2):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:0):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:0):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:56):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:56):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:56):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:45):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:32):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:32):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:4):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:42):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:42):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:28):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:28):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:28):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:8):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:8):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:7):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:7):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:57):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:57):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:40):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:41):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:28):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:0):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:59):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:35):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul11 13:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 17:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 17:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:45):\r\rTest News Post\r\r__________________________________________________________\r
\ No newline at end of file
--- /dev/null
+Categories:
+ TestBundle:
+ Type:
+ - 0
+ - 2
+ Name: TestBundle
+ Articles: {}
+ SubCats:
+ NestedBundle:
+ Type:
+ - 0
+ - 2
+ Name: NestedBundle
+ Articles: {}
+ SubCats:
+ NestedCat:
+ Type:
+ - 0
+ - 3
+ Name: NestedCat
+ Articles: {}
+ SubCats: {}
+ count: []
+ addsn: []
+ deletesn: []
+ guid: []
+ count: []
+ addsn: []
+ deletesn: []
+ guid: []
+ TestSubCat:
+ Type:
+ - 0
+ - 3
+ Name: TestSubCat
+ Articles:
+ 1:
+ Title: SubCatArt
+ Poster: Halcyon 1.9.2
+ Date:
+ - 7
+ - 228
+ - 0
+ - 0
+ - 0
+ - 254
+ - 252
+ - 246
+ PrevArt:
+ - 0
+ - 0
+ - 0
+ - 0
+ NextArt:
+ - 0
+ - 0
+ - 0
+ - 0
+ ParentArt:
+ - 0
+ - 0
+ - 0
+ - 0
+ FirstChildArtArt:
+ - 0
+ - 0
+ - 0
+ - 0
+ DataFlav:
+ - 116
+ - 101
+ - 120
+ - 116
+ - 47
+ - 112
+ - 108
+ - 97
+ - 105
+ - 110
+ Data: I'm an article in a subcategory!
+ SubCats: {}
+ count: []
+ addsn: []
+ deletesn: []
+ guid: []
+ count: []
+ addsn: []
+ deletesn: []
+ guid: []
+ TestCat:
+ Type:
+ - 0
+ - 3
+ Name: TestCat
+ Articles:
+ 1:
+ Title: TestArt
+ Poster: Halcyon 1.9.2
+ Date:
+ - 7
+ - 228
+ - 0
+ - 0
+ - 0
+ - 254
+ - 252
+ - 204
+ PrevArt:
+ - 0
+ - 0
+ - 0
+ - 0
+ NextArt:
+ - 0
+ - 0
+ - 0
+ - 2
+ ParentArt:
+ - 0
+ - 0
+ - 0
+ - 0
+ FirstChildArtArt:
+ - 0
+ - 0
+ - 0
+ - 2
+ DataFlav:
+ - 116
+ - 101
+ - 120
+ - 116
+ - 47
+ - 112
+ - 108
+ - 97
+ - 105
+ - 110
+ Data: TestArt Body
+ 2:
+ Title: 'Re: TestArt'
+ Poster: Halcyon 1.9.2
+ Date:
+ - 7
+ - 228
+ - 0
+ - 0
+ - 0
+ - 254
+ - 252
+ - 216
+ PrevArt:
+ - 0
+ - 0
+ - 0
+ - 1
+ NextArt:
+ - 0
+ - 0
+ - 0
+ - 3
+ ParentArt:
+ - 0
+ - 0
+ - 0
+ - 1
+ FirstChildArtArt:
+ - 0
+ - 0
+ - 0
+ - 0
+ DataFlav:
+ - 116
+ - 101
+ - 120
+ - 116
+ - 47
+ - 112
+ - 108
+ - 97
+ - 105
+ - 110
+ Data: I'm a reply
+ 3:
+ Title: TestArt 2
+ Poster: Halcyon 1.9.2
+ Date:
+ - 7
+ - 228
+ - 0
+ - 0
+ - 0
+ - 254
+ - 253
+ - 6
+ PrevArt:
+ - 0
+ - 0
+ - 0
+ - 2
+ NextArt:
+ - 0
+ - 0
+ - 0
+ - 0
+ ParentArt:
+ - 0
+ - 0
+ - 0
+ - 0
+ FirstChildArtArt:
+ - 0
+ - 0
+ - 0
+ - 0
+ DataFlav:
+ - 116
+ - 101
+ - 120
+ - 116
+ - 47
+ - 112
+ - 108
+ - 97
+ - 105
+ - 110
+ Data: Hello world
+ SubCats: {}
+ count: []
+ addsn: []
+ deletesn: []
+ guid: []
--- /dev/null
+Login: admin
+Name: admin
+Password: $2a$04$2itGEYx8C1N5bsfRSoC9JuonS3I4YfnyVPZHLSwp7kEInRX0yoB.a
+Access:
+- 255
+- 255
+- 255
+- 255
+- 255
+- 255
+- 0
+- 0
+
--- /dev/null
+Login: guest
+Name: guest
+Password: $2a$04$9P/jgLn1fR9TjSoWL.rKxuN6g.1TSpf2o6Hw.aaRuBwrWIJNwsKkS
+Access:
+- 125
+- 240
+- 12
+- 239
+- 171
+- 128
+- 0
+- 0
--- /dev/null
+Name: Halcyon's Test Server
+Description: Experimental Hotline server
+FileRoot: conFiles/
+EnableTrackerRegistration: false
+Trackers:
+ - hltracker.com:5499
\ No newline at end of file
return art
}
-//
-//func (n *ThreadedNewsYAML) GetNewsCatByPath(paths []string) map[string]hotline.NewsCategoryListData15 {
-// n.mu.Lock()
-// defer n.mu.Unlock()
-//
-// cats := n.getCatByPath(paths)
-//
-// return cats
-//}
-
func (n *ThreadedNewsYAML) GetCategories(paths []string) []hotline.NewsCategoryListData15 {
n.mu.Lock()
defer n.mu.Unlock()
--- /dev/null
+package mobius
+
+import (
+ "github.com/stretchr/testify/assert"
+ "os"
+ "testing"
+)
+
+type TestData struct {
+ Name string `yaml:"name"`
+ Value int `yaml:"value"`
+}
+
+func TestLoadFromYAMLFile(t *testing.T) {
+ tests := []struct {
+ name string
+ fileName string
+ content string
+ wantData TestData
+ wantErr bool
+ }{
+ {
+ name: "Valid YAML file",
+ fileName: "valid.yaml",
+ content: "name: Test\nvalue: 123\n",
+ wantData: TestData{Name: "Test", Value: 123},
+ wantErr: false,
+ },
+ {
+ name: "File not found",
+ fileName: "nonexistent.yaml",
+ content: "",
+ wantData: TestData{},
+ wantErr: true,
+ },
+ {
+ name: "Invalid YAML content",
+ fileName: "invalid.yaml",
+ content: "name: Test\nvalue: invalid_int\n",
+ wantData: TestData{},
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Setup: Create a temporary file with the provided content if content is not empty
+ if tt.content != "" {
+ err := os.WriteFile(tt.fileName, []byte(tt.content), 0644)
+ assert.NoError(t, err)
+ defer os.Remove(tt.fileName) // Cleanup the file after the test
+ }
+
+ var data TestData
+ err := loadFromYAMLFile(tt.fileName, &data)
+
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.wantData, data)
+ }
+ })
+ }
+}
--- /dev/null
+package mobius
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/binary"
+ "fmt"
+ "github.com/jhalter/mobius/hotline"
+ "golang.org/x/text/encoding/charmap"
+ "io"
+ "math/big"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// Converts bytes from Mac Roman encoding to UTF-8
+var txtDecoder = charmap.Macintosh.NewDecoder()
+
+// Converts bytes from UTF-8 to Mac Roman encoding
+var txtEncoder = charmap.Macintosh.NewEncoder()
+
+// Assign functions to handle specific Hotline transaction types
+func RegisterHandlers(srv *hotline.Server) {
+ srv.HandleFunc(hotline.TranAgreed, HandleTranAgreed)
+ srv.HandleFunc(hotline.TranChatSend, HandleChatSend)
+ srv.HandleFunc(hotline.TranDelNewsArt, HandleDelNewsArt)
+ srv.HandleFunc(hotline.TranDelNewsItem, HandleDelNewsItem)
+ srv.HandleFunc(hotline.TranDeleteFile, HandleDeleteFile)
+ srv.HandleFunc(hotline.TranDeleteUser, HandleDeleteUser)
+ srv.HandleFunc(hotline.TranDisconnectUser, HandleDisconnectUser)
+ srv.HandleFunc(hotline.TranDownloadFile, HandleDownloadFile)
+ srv.HandleFunc(hotline.TranDownloadFldr, HandleDownloadFolder)
+ srv.HandleFunc(hotline.TranGetClientInfoText, HandleGetClientInfoText)
+ srv.HandleFunc(hotline.TranGetFileInfo, HandleGetFileInfo)
+ srv.HandleFunc(hotline.TranGetFileNameList, HandleGetFileNameList)
+ srv.HandleFunc(hotline.TranGetMsgs, HandleGetMsgs)
+ srv.HandleFunc(hotline.TranGetNewsArtData, HandleGetNewsArtData)
+ srv.HandleFunc(hotline.TranGetNewsArtNameList, HandleGetNewsArtNameList)
+ srv.HandleFunc(hotline.TranGetNewsCatNameList, HandleGetNewsCatNameList)
+ srv.HandleFunc(hotline.TranGetUser, HandleGetUser)
+ srv.HandleFunc(hotline.TranGetUserNameList, HandleGetUserNameList)
+ srv.HandleFunc(hotline.TranInviteNewChat, HandleInviteNewChat)
+ srv.HandleFunc(hotline.TranInviteToChat, HandleInviteToChat)
+ srv.HandleFunc(hotline.TranJoinChat, HandleJoinChat)
+ srv.HandleFunc(hotline.TranKeepAlive, HandleKeepAlive)
+ srv.HandleFunc(hotline.TranLeaveChat, HandleLeaveChat)
+ srv.HandleFunc(hotline.TranListUsers, HandleListUsers)
+ srv.HandleFunc(hotline.TranMoveFile, HandleMoveFile)
+ srv.HandleFunc(hotline.TranNewFolder, HandleNewFolder)
+ srv.HandleFunc(hotline.TranNewNewsCat, HandleNewNewsCat)
+ srv.HandleFunc(hotline.TranNewNewsFldr, HandleNewNewsFldr)
+ srv.HandleFunc(hotline.TranNewUser, HandleNewUser)
+ srv.HandleFunc(hotline.TranUpdateUser, HandleUpdateUser)
+ srv.HandleFunc(hotline.TranOldPostNews, HandleTranOldPostNews)
+ srv.HandleFunc(hotline.TranPostNewsArt, HandlePostNewsArt)
+ srv.HandleFunc(hotline.TranRejectChatInvite, HandleRejectChatInvite)
+ srv.HandleFunc(hotline.TranSendInstantMsg, HandleSendInstantMsg)
+ srv.HandleFunc(hotline.TranSetChatSubject, HandleSetChatSubject)
+ srv.HandleFunc(hotline.TranMakeFileAlias, HandleMakeAlias)
+ srv.HandleFunc(hotline.TranSetClientUserInfo, HandleSetClientUserInfo)
+ srv.HandleFunc(hotline.TranSetFileInfo, HandleSetFileInfo)
+ srv.HandleFunc(hotline.TranSetUser, HandleSetUser)
+ srv.HandleFunc(hotline.TranUploadFile, HandleUploadFile)
+ srv.HandleFunc(hotline.TranUploadFldr, HandleUploadFolder)
+ srv.HandleFunc(hotline.TranUserBroadcast, HandleUserBroadcast)
+ srv.HandleFunc(hotline.TranDownloadBanner, HandleDownloadBanner)
+}
+
+func HandleChatSend(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessSendChat) {
+ return cc.NewErrReply(t, "You are not allowed to participate in chat.")
+ }
+
+ // Truncate long usernames
+ // %13.13s: This means a string that is right-aligned in a field of 13 characters.
+ // If the string is longer than 13 characters, it will be truncated to 13 characters.
+ formattedMsg := fmt.Sprintf("\r%13.13s: %s", cc.UserName, t.GetField(hotline.FieldData).Data)
+
+ // By holding the option key, Hotline chat allows users to send /me formatted messages like:
+ // *** Halcyon does stuff
+ // This is indicated by the presence of the optional field FieldChatOptions set to a value of 1.
+ // Most clients do not send this option for normal chat messages.
+ if t.GetField(hotline.FieldChatOptions).Data != nil && bytes.Equal(t.GetField(hotline.FieldChatOptions).Data, []byte{0, 1}) {
+ formattedMsg = fmt.Sprintf("\r*** %s %s", cc.UserName, t.GetField(hotline.FieldData).Data)
+ }
+
+ // Truncate the message to the limit. This does not handle the edge case of a string ending on multibyte character.
+ formattedMsg = formattedMsg[:min(len(formattedMsg), hotline.LimitChatMsg)]
+
+ // The ChatID field is used to identify messages as belonging to a private chat.
+ // All clients *except* Frogblast omit this field for public chat, but Frogblast sends a value of 00 00 00 00.
+ chatID := t.GetField(hotline.FieldChatID).Data
+ if chatID != nil && !bytes.Equal([]byte{0, 0, 0, 0}, chatID) {
+
+ // send the message to all connected clients of the private chat
+ for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) {
+ res = append(res, hotline.NewTransaction(
+ hotline.TranChatMsg,
+ c.ID,
+ hotline.NewField(hotline.FieldChatID, chatID),
+ hotline.NewField(hotline.FieldData, []byte(formattedMsg)),
+ ))
+ }
+ return res
+ }
+
+ //cc.Server.mux.Lock()
+ for _, c := range cc.Server.ClientMgr.List() {
+ if c == nil || cc.Account == nil {
+ continue
+ }
+ // Skip clients that do not have the read chat permission.
+ if c.Authorize(hotline.AccessReadChat) {
+ res = append(res, hotline.NewTransaction(hotline.TranChatMsg, c.ID, hotline.NewField(hotline.FieldData, []byte(formattedMsg))))
+ }
+ }
+ //cc.Server.mux.Unlock()
+
+ return res
+}
+
+// HandleSendInstantMsg sends instant message to the user on the current server.
+// Fields used in the request:
+//
+// 103 User Type
+// 113 Options
+// One of the following values:
+// - User message (myOpt_UserMessage = 1)
+// - Refuse message (myOpt_RefuseMessage = 2)
+// - Refuse chat (myOpt_RefuseChat = 3)
+// - Automatic response (myOpt_AutomaticResponse = 4)"
+// 101 Data Optional
+// 214 Quoting message Optional
+//
+// Fields used in the reply:
+// None
+func HandleSendInstantMsg(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessSendPrivMsg) {
+ return cc.NewErrReply(t, "You are not allowed to send private messages.")
+ }
+
+ msg := t.GetField(hotline.FieldData)
+ userID := t.GetField(hotline.FieldUserID)
+
+ reply := hotline.NewTransaction(
+ hotline.TranServerMsg,
+ [2]byte(userID.Data),
+ hotline.NewField(hotline.FieldData, msg.Data),
+ hotline.NewField(hotline.FieldUserName, cc.UserName),
+ hotline.NewField(hotline.FieldUserID, cc.ID[:]),
+ hotline.NewField(hotline.FieldOptions, []byte{0, 1}),
+ )
+
+ // Later versions of Hotline include the original message in the FieldQuotingMsg field so
+ // the receiving client can display both the received message and what it is in reply to
+ if t.GetField(hotline.FieldQuotingMsg).Data != nil {
+ reply.Fields = append(reply.Fields, hotline.NewField(hotline.FieldQuotingMsg, t.GetField(hotline.FieldQuotingMsg).Data))
+ }
+
+ otherClient := cc.Server.ClientMgr.Get([2]byte(userID.Data))
+ if otherClient == nil {
+ return res
+ }
+
+ // Check if target user has "Refuse private messages" flag
+ if otherClient.Flags.IsSet(hotline.UserFlagRefusePM) {
+ res = append(res,
+ hotline.NewTransaction(
+ hotline.TranServerMsg,
+ cc.ID,
+ hotline.NewField(hotline.FieldData, []byte(string(otherClient.UserName)+" does not accept private messages.")),
+ hotline.NewField(hotline.FieldUserName, otherClient.UserName),
+ hotline.NewField(hotline.FieldUserID, otherClient.ID[:]),
+ hotline.NewField(hotline.FieldOptions, []byte{0, 2}),
+ ),
+ )
+ } else {
+ res = append(res, reply)
+ }
+
+ // Respond with auto reply if other client has it enabled
+ if len(otherClient.AutoReply) > 0 {
+ res = append(res,
+ hotline.NewTransaction(
+ hotline.TranServerMsg,
+ cc.ID,
+ hotline.NewField(hotline.FieldData, otherClient.AutoReply),
+ hotline.NewField(hotline.FieldUserName, otherClient.UserName),
+ hotline.NewField(hotline.FieldUserID, otherClient.ID[:]),
+ hotline.NewField(hotline.FieldOptions, []byte{0, 1}),
+ ),
+ )
+ }
+
+ return append(res, cc.NewReply(t))
+}
+
+var fileTypeFLDR = [4]byte{0x66, 0x6c, 0x64, 0x72}
+
+func HandleGetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ fileName := t.GetField(hotline.FieldFileName).Data
+ filePath := t.GetField(hotline.FieldFilePath).Data
+
+ fullFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, fileName)
+ if err != nil {
+ return res
+ }
+
+ fw, err := hotline.NewFileWrapper(cc.Server.FS, fullFilePath, 0)
+ if err != nil {
+ return res
+ }
+
+ encodedName, err := txtEncoder.String(fw.Name)
+ if err != nil {
+ return res
+ }
+
+ fields := []hotline.Field{
+ hotline.NewField(hotline.FieldFileName, []byte(encodedName)),
+ hotline.NewField(hotline.FieldFileTypeString, fw.Ffo.FlatFileInformationFork.FriendlyType()),
+ hotline.NewField(hotline.FieldFileCreatorString, fw.Ffo.FlatFileInformationFork.FriendlyCreator()),
+ hotline.NewField(hotline.FieldFileType, fw.Ffo.FlatFileInformationFork.TypeSignature[:]),
+ hotline.NewField(hotline.FieldFileCreateDate, fw.Ffo.FlatFileInformationFork.CreateDate[:]),
+ hotline.NewField(hotline.FieldFileModifyDate, fw.Ffo.FlatFileInformationFork.ModifyDate[:]),
+ }
+
+ // Include the optional FileComment field if there is a comment.
+ if len(fw.Ffo.FlatFileInformationFork.Comment) != 0 {
+ fields = append(fields, hotline.NewField(hotline.FieldFileComment, fw.Ffo.FlatFileInformationFork.Comment))
+ }
+
+ // Include the FileSize field for files.
+ if fw.Ffo.FlatFileInformationFork.TypeSignature != fileTypeFLDR {
+ fields = append(fields, hotline.NewField(hotline.FieldFileSize, fw.TotalSize()))
+ }
+
+ res = append(res, cc.NewReply(t, fields...))
+ return res
+}
+
+// HandleSetFileInfo updates a file or folder Name and/or comment from the Get Info window
+// Fields used in the request:
+// * 201 File Name
+// * 202 File path Optional
+// * 211 File new Name Optional
+// * 210 File comment Optional
+// Fields used in the reply: None
+func HandleSetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ fileName := t.GetField(hotline.FieldFileName).Data
+ filePath := t.GetField(hotline.FieldFilePath).Data
+
+ fullFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, fileName)
+ if err != nil {
+ return res
+ }
+
+ fi, err := cc.Server.FS.Stat(fullFilePath)
+ if err != nil {
+ return res
+ }
+
+ hlFile, err := hotline.NewFileWrapper(cc.Server.FS, fullFilePath, 0)
+ if err != nil {
+ return res
+ }
+ if t.GetField(hotline.FieldFileComment).Data != nil {
+ switch mode := fi.Mode(); {
+ case mode.IsDir():
+ if !cc.Authorize(hotline.AccessSetFolderComment) {
+ return cc.NewErrReply(t, "You are not allowed to set comments for folders.")
+ }
+ case mode.IsRegular():
+ if !cc.Authorize(hotline.AccessSetFileComment) {
+ return cc.NewErrReply(t, "You are not allowed to set comments for files.")
+ }
+ }
+
+ if err := hlFile.Ffo.FlatFileInformationFork.SetComment(t.GetField(hotline.FieldFileComment).Data); err != nil {
+ return res
+ }
+ w, err := hlFile.InfoForkWriter()
+ if err != nil {
+ return res
+ }
+ _, err = io.Copy(w, &hlFile.Ffo.FlatFileInformationFork)
+ if err != nil {
+ return res
+ }
+ }
+
+ fullNewFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, t.GetField(hotline.FieldFileNewName).Data)
+ if err != nil {
+ return nil
+ }
+
+ fileNewName := t.GetField(hotline.FieldFileNewName).Data
+
+ if fileNewName != nil {
+ switch mode := fi.Mode(); {
+ case mode.IsDir():
+ if !cc.Authorize(hotline.AccessRenameFolder) {
+ return cc.NewErrReply(t, "You are not allowed to rename folders.")
+ }
+ err = os.Rename(fullFilePath, fullNewFilePath)
+ if os.IsNotExist(err) {
+ return cc.NewErrReply(t, "Cannot rename folder "+string(fileName)+" because it does not exist or cannot be found.")
+
+ }
+ case mode.IsRegular():
+ if !cc.Authorize(hotline.AccessRenameFile) {
+ return cc.NewErrReply(t, "You are not allowed to rename files.")
+ }
+ fileDir, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, []byte{})
+ if err != nil {
+ return nil
+ }
+ hlFile.Name, err = txtDecoder.String(string(fileNewName))
+ if err != nil {
+ return res
+ }
+
+ err = hlFile.Move(fileDir)
+ if os.IsNotExist(err) {
+ return cc.NewErrReply(t, "Cannot rename file "+string(fileName)+" because it does not exist or cannot be found.")
+ }
+ if err != nil {
+ return res
+ }
+ }
+ }
+
+ res = append(res, cc.NewReply(t))
+ return res
+}
+
+// HandleDeleteFile deletes a file or folder
+// Fields used in the request:
+// * 201 File Name
+// * 202 File path
+// Fields used in the reply: none
+func HandleDeleteFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ fileName := t.GetField(hotline.FieldFileName).Data
+ filePath := t.GetField(hotline.FieldFilePath).Data
+
+ fullFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, fileName)
+ if err != nil {
+ return res
+ }
+
+ hlFile, err := hotline.NewFileWrapper(cc.Server.FS, fullFilePath, 0)
+ if err != nil {
+ return res
+ }
+
+ fi, err := hlFile.DataFile()
+ if err != nil {
+ return cc.NewErrReply(t, "Cannot delete file "+string(fileName)+" because it does not exist or cannot be found.")
+ }
+
+ switch mode := fi.Mode(); {
+ case mode.IsDir():
+ if !cc.Authorize(hotline.AccessDeleteFolder) {
+ return cc.NewErrReply(t, "You are not allowed to delete folders.")
+ }
+ case mode.IsRegular():
+ if !cc.Authorize(hotline.AccessDeleteFile) {
+ return cc.NewErrReply(t, "You are not allowed to delete files.")
+ }
+ }
+
+ if err := hlFile.Delete(); err != nil {
+ return res
+ }
+
+ res = append(res, cc.NewReply(t))
+ return res
+}
+
+// HandleMoveFile moves files or folders. Note: seemingly not documented
+func HandleMoveFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ fileName := string(t.GetField(hotline.FieldFileName).Data)
+
+ filePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, t.GetField(hotline.FieldFilePath).Data, t.GetField(hotline.FieldFileName).Data)
+ if err != nil {
+ return res
+ }
+
+ fileNewPath, err := hotline.ReadPath(cc.Server.Config.FileRoot, t.GetField(hotline.FieldFileNewPath).Data, nil)
+ if err != nil {
+ return res
+ }
+
+ cc.Logger.Info("Move file", "src", filePath+"/"+fileName, "dst", fileNewPath+"/"+fileName)
+
+ hlFile, err := hotline.NewFileWrapper(cc.Server.FS, filePath, 0)
+ if err != nil {
+ return res
+ }
+
+ fi, err := hlFile.DataFile()
+ if err != nil {
+ return cc.NewErrReply(t, "Cannot delete file "+fileName+" because it does not exist or cannot be found.")
+ }
+ switch mode := fi.Mode(); {
+ case mode.IsDir():
+ if !cc.Authorize(hotline.AccessMoveFolder) {
+ return cc.NewErrReply(t, "You are not allowed to move folders.")
+ }
+ case mode.IsRegular():
+ if !cc.Authorize(hotline.AccessMoveFile) {
+ return cc.NewErrReply(t, "You are not allowed to move files.")
+ }
+ }
+ if err := hlFile.Move(fileNewPath); err != nil {
+ return res
+ }
+ // TODO: handle other possible errors; e.g. fileWrapper delete fails due to fileWrapper permission issue
+
+ res = append(res, cc.NewReply(t))
+ return res
+}
+
+func HandleNewFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessCreateFolder) {
+ return cc.NewErrReply(t, "You are not allowed to create folders.")
+ }
+ folderName := string(t.GetField(hotline.FieldFileName).Data)
+
+ folderName = path.Join("/", folderName)
+
+ var subPath string
+
+ // FieldFilePath is only present for nested paths
+ if t.GetField(hotline.FieldFilePath).Data != nil {
+ var newFp hotline.FilePath
+ _, err := newFp.Write(t.GetField(hotline.FieldFilePath).Data)
+ if err != nil {
+ return res
+ }
+
+ for _, pathItem := range newFp.Items {
+ subPath = filepath.Join("/", subPath, string(pathItem.Name))
+ }
+ }
+ newFolderPath := path.Join(cc.Server.Config.FileRoot, subPath, folderName)
+ newFolderPath, err := txtDecoder.String(newFolderPath)
+ if err != nil {
+ return res
+ }
+
+ // TODO: check path and folder Name lengths
+
+ if _, err := cc.Server.FS.Stat(newFolderPath); !os.IsNotExist(err) {
+ msg := fmt.Sprintf("Cannot create folder \"%s\" because there is already a file or folder with that Name.", folderName)
+ return cc.NewErrReply(t, msg)
+ }
+
+ if err := cc.Server.FS.Mkdir(newFolderPath, 0777); err != nil {
+ msg := fmt.Sprintf("Cannot create folder \"%s\" because an error occurred.", folderName)
+ return cc.NewErrReply(t, msg)
+ }
+
+ return append(res, cc.NewReply(t))
+}
+
+func HandleSetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessModifyUser) {
+ return cc.NewErrReply(t, "You are not allowed to modify accounts.")
+ }
+
+ login := t.GetField(hotline.FieldUserLogin).DecodeObfuscatedString()
+ userName := string(t.GetField(hotline.FieldUserName).Data)
+
+ newAccessLvl := t.GetField(hotline.FieldUserAccess).Data
+
+ account := cc.Server.AccountManager.Get(login)
+ if account == nil {
+ return cc.NewErrReply(t, "Account not found.")
+ }
+ account.Name = userName
+ copy(account.Access[:], newAccessLvl)
+
+ // If the password field is cleared in the Hotline edit user UI, the SetUser transaction does
+ // not include FieldUserPassword
+ if t.GetField(hotline.FieldUserPassword).Data == nil {
+ account.Password = hotline.HashAndSalt([]byte(""))
+ }
+
+ if !bytes.Equal([]byte{0}, t.GetField(hotline.FieldUserPassword).Data) {
+ account.Password = hotline.HashAndSalt(t.GetField(hotline.FieldUserPassword).Data)
+ }
+
+ err := cc.Server.AccountManager.Update(*account, account.Login)
+ if err != nil {
+ cc.Logger.Error("Error updating account", "Err", err)
+ }
+
+ // Notify connected clients logged in as the user of the new access level
+ for _, c := range cc.Server.ClientMgr.List() {
+ if c.Account.Login == login {
+ newT := hotline.NewTransaction(hotline.TranUserAccess, c.ID, hotline.NewField(hotline.FieldUserAccess, newAccessLvl))
+ res = append(res, newT)
+
+ if c.Authorize(hotline.AccessDisconUser) {
+ c.Flags.Set(hotline.UserFlagAdmin, 1)
+ } else {
+ c.Flags.Set(hotline.UserFlagAdmin, 0)
+ }
+
+ c.Account.Access = account.Access
+
+ cc.SendAll(
+ hotline.TranNotifyChangeUser,
+ hotline.NewField(hotline.FieldUserID, c.ID[:]),
+ hotline.NewField(hotline.FieldUserFlags, c.Flags[:]),
+ hotline.NewField(hotline.FieldUserName, c.UserName),
+ hotline.NewField(hotline.FieldUserIconID, c.Icon),
+ )
+ }
+ }
+
+ return append(res, cc.NewReply(t))
+}
+
+func HandleGetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessOpenUser) {
+ return cc.NewErrReply(t, "You are not allowed to view accounts.")
+ }
+
+ account := cc.Server.AccountManager.Get(string(t.GetField(hotline.FieldUserLogin).Data))
+ if account == nil {
+ return cc.NewErrReply(t, "Account does not exist.")
+ }
+
+ return append(res, cc.NewReply(t,
+ hotline.NewField(hotline.FieldUserName, []byte(account.Name)),
+ hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString(t.GetField(hotline.FieldUserLogin).Data)),
+ hotline.NewField(hotline.FieldUserPassword, []byte(account.Password)),
+ hotline.NewField(hotline.FieldUserAccess, account.Access[:]),
+ ))
+}
+
+func HandleListUsers(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessOpenUser) {
+ return cc.NewErrReply(t, "You are not allowed to view accounts.")
+ }
+
+ var userFields []hotline.Field
+ for _, acc := range cc.Server.AccountManager.List() {
+ b, err := io.ReadAll(&acc)
+ if err != nil {
+ cc.Logger.Error("Error reading account", "Account", acc.Login, "Err", err)
+ continue
+ }
+
+ userFields = append(userFields, hotline.NewField(hotline.FieldData, b))
+ }
+
+ return append(res, cc.NewReply(t, userFields...))
+}
+
+// HandleUpdateUser is used by the v1.5+ multi-user editor to perform account editing for multiple users at a time.
+// An update can be a mix of these actions:
+// * Create user
+// * Delete user
+// * Modify user (including renaming the account login)
+//
+// The Transaction sent by the client includes one data field per user that was modified. This data field in turn
+// contains another data field encoded in its payload with a varying number of sub fields depending on which action is
+// performed. This seems to be the only place in the Hotline protocol where a data field contains another data field.
+func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ for _, field := range t.Fields {
+ var subFields []hotline.Field
+
+ // Create a new scanner for parsing incoming bytes into transaction tokens
+ scanner := bufio.NewScanner(bytes.NewReader(field.Data[2:]))
+ scanner.Split(hotline.FieldScanner)
+
+ for i := 0; i < int(binary.BigEndian.Uint16(field.Data[0:2])); i++ {
+ scanner.Scan()
+
+ var field hotline.Field
+ if _, err := field.Write(scanner.Bytes()); err != nil {
+ return res
+ }
+ subFields = append(subFields, field)
+ }
+
+ // If there's only one subfield, that indicates this is a delete operation for the login in FieldData
+ if len(subFields) == 1 {
+ if !cc.Authorize(hotline.AccessDeleteUser) {
+ return cc.NewErrReply(t, "You are not allowed to delete accounts.")
+ }
+
+ login := string(hotline.EncodeString(hotline.GetField(hotline.FieldData, &subFields).Data))
+
+ cc.Logger.Info("DeleteUser", "login", login)
+
+ if err := cc.Server.AccountManager.Delete(login); err != nil {
+ cc.Logger.Error("Error deleting account", "Err", err)
+ return res
+ }
+
+ for _, client := range cc.Server.ClientMgr.List() {
+ if client.Account.Login == login {
+ // "You are logged in with an account which was deleted."
+
+ res = append(res,
+ hotline.NewTransaction(hotline.TranServerMsg, [2]byte{},
+ hotline.NewField(hotline.FieldData, []byte("You are logged in with an account which was deleted.")),
+ hotline.NewField(hotline.FieldChatOptions, []byte{0}),
+ ),
+ )
+
+ go func(c *hotline.ClientConn) {
+ time.Sleep(3 * time.Second)
+ c.Disconnect()
+ }(client)
+ }
+ }
+
+ continue
+ }
+
+ // login of the account to update
+ var accountToUpdate, loginToRename string
+
+ // If FieldData is included, this is a rename operation where FieldData contains the login of the existing
+ // account and FieldUserLogin contains the new login.
+ if hotline.GetField(hotline.FieldData, &subFields) != nil {
+ loginToRename = string(hotline.EncodeString(hotline.GetField(hotline.FieldData, &subFields).Data))
+ }
+ userLogin := string(hotline.EncodeString(hotline.GetField(hotline.FieldUserLogin, &subFields).Data))
+ if loginToRename != "" {
+ accountToUpdate = loginToRename
+ } else {
+ accountToUpdate = userLogin
+ }
+
+ // Check if accountToUpdate has an existing account. If so, we know we are updating an existing user.
+ if acc := cc.Server.AccountManager.Get(accountToUpdate); acc != nil {
+ if loginToRename != "" {
+ cc.Logger.Info("RenameUser", "prevLogin", accountToUpdate, "newLogin", userLogin)
+ } else {
+ cc.Logger.Info("UpdateUser", "login", accountToUpdate)
+ }
+
+ // Account exists, so this is an update action.
+ if !cc.Authorize(hotline.AccessModifyUser) {
+ return cc.NewErrReply(t, "You are not allowed to modify accounts.")
+ }
+
+ // This part is a bit tricky. There are three possibilities:
+ // 1) The transaction is intended to update the password.
+ // In this case, FieldUserPassword is sent with the new password.
+ // 2) The transaction is intended to remove the password.
+ // In this case, FieldUserPassword is not sent.
+ // 3) The transaction updates the users access bits, but not the password.
+ // In this case, FieldUserPassword is sent with zero as the only byte.
+ if hotline.GetField(hotline.FieldUserPassword, &subFields) != nil {
+ newPass := hotline.GetField(hotline.FieldUserPassword, &subFields).Data
+ if !bytes.Equal([]byte{0}, newPass) {
+ acc.Password = hotline.HashAndSalt(newPass)
+ }
+ } else {
+ acc.Password = hotline.HashAndSalt([]byte(""))
+ }
+
+ if hotline.GetField(hotline.FieldUserAccess, &subFields) != nil {
+ copy(acc.Access[:], hotline.GetField(hotline.FieldUserAccess, &subFields).Data)
+ }
+
+ acc.Name = string(hotline.GetField(hotline.FieldUserName, &subFields).Data)
+
+ err := cc.Server.AccountManager.Update(*acc, string(hotline.EncodeString(hotline.GetField(hotline.FieldUserLogin, &subFields).Data)))
+
+ if err != nil {
+ return res
+ }
+ } else {
+ if !cc.Authorize(hotline.AccessCreateUser) {
+ return cc.NewErrReply(t, "You are not allowed to create new accounts.")
+ }
+
+ cc.Logger.Info("CreateUser", "login", userLogin)
+
+ var newAccess hotline.AccessBitmap
+ copy(newAccess[:], hotline.GetField(hotline.FieldUserAccess, &subFields).Data)
+
+ // Prevent account from creating new account with greater permission
+ for i := 0; i < 64; i++ {
+ if newAccess.IsSet(i) {
+ if !cc.Authorize(i) {
+ return cc.NewErrReply(t, "Cannot create account with more access than yourself.")
+ }
+ }
+ }
+
+ account := hotline.NewAccount(
+ userLogin,
+ string(hotline.GetField(hotline.FieldUserName, &subFields).Data),
+ string(hotline.GetField(hotline.FieldUserPassword, &subFields).Data),
+ newAccess,
+ )
+
+ err := cc.Server.AccountManager.Create(*account)
+ if err != nil {
+ return cc.NewErrReply(t, "Cannot create account because there is already an account with that login.")
+ }
+ }
+ }
+
+ return append(res, cc.NewReply(t))
+}
+
+// HandleNewUser creates a new user account
+func HandleNewUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessCreateUser) {
+ return cc.NewErrReply(t, "You are not allowed to create new accounts.")
+ }
+
+ login := t.GetField(hotline.FieldUserLogin).DecodeObfuscatedString()
+
+ // If the account already exists, reply with an error.
+ if account := cc.Server.AccountManager.Get(login); account != nil {
+ return cc.NewErrReply(t, "Cannot create account "+login+" because there is already an account with that login.")
+ }
+
+ var newAccess hotline.AccessBitmap
+ copy(newAccess[:], t.GetField(hotline.FieldUserAccess).Data)
+
+ // Prevent account from creating new account with greater permission
+ for i := 0; i < 64; i++ {
+ if newAccess.IsSet(i) {
+ if !cc.Authorize(i) {
+ return cc.NewErrReply(t, "Cannot create account with more access than yourself.")
+ }
+ }
+ }
+
+ account := hotline.NewAccount(login, string(t.GetField(hotline.FieldUserName).Data), string(t.GetField(hotline.FieldUserPassword).Data), newAccess)
+
+ err := cc.Server.AccountManager.Create(*account)
+ if err != nil {
+ return cc.NewErrReply(t, "Cannot create account because there is already an account with that login.")
+ }
+
+ return append(res, cc.NewReply(t))
+}
+
+func HandleDeleteUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessDeleteUser) {
+ return cc.NewErrReply(t, "You are not allowed to delete accounts.")
+ }
+
+ login := t.GetField(hotline.FieldUserLogin).DecodeObfuscatedString()
+
+ if err := cc.Server.AccountManager.Delete(login); err != nil {
+ cc.Logger.Error("Error deleting account", "Err", err)
+ return res
+ }
+
+ for _, client := range cc.Server.ClientMgr.List() {
+ if client.Account.Login == login {
+ res = append(res,
+ hotline.NewTransaction(hotline.TranServerMsg, client.ID,
+ hotline.NewField(hotline.FieldData, []byte("You are logged in with an account which was deleted.")),
+ hotline.NewField(hotline.FieldChatOptions, []byte{2}),
+ ),
+ )
+
+ go func(c *hotline.ClientConn) {
+ time.Sleep(2 * time.Second)
+ c.Disconnect()
+ }(client)
+ }
+ }
+
+ return append(res, cc.NewReply(t))
+}
+
+// HandleUserBroadcast sends an Administrator Message to all connected clients of the server
+func HandleUserBroadcast(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessBroadcast) {
+ return cc.NewErrReply(t, "You are not allowed to send broadcast messages.")
+ }
+
+ cc.SendAll(
+ hotline.TranServerMsg,
+ hotline.NewField(hotline.FieldData, t.GetField(hotline.FieldData).Data),
+ hotline.NewField(hotline.FieldChatOptions, []byte{0}),
+ )
+
+ return append(res, cc.NewReply(t))
+}
+
+// HandleGetClientInfoText returns user information for the specific user.
+//
+// Fields used in the request:
+// 103 User Type
+//
+// Fields used in the reply:
+// 102 User Name
+// 101 Data User info text string
+func HandleGetClientInfoText(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessGetClientInfo) {
+ return cc.NewErrReply(t, "You are not allowed to get client info.")
+ }
+
+ clientID := t.GetField(hotline.FieldUserID).Data
+
+ clientConn := cc.Server.ClientMgr.Get(hotline.ClientID(clientID))
+ if clientConn == nil {
+ return cc.NewErrReply(t, "User not found.")
+ }
+
+ return append(res, cc.NewReply(t,
+ hotline.NewField(hotline.FieldData, []byte(clientConn.String())),
+ hotline.NewField(hotline.FieldUserName, clientConn.UserName),
+ ))
+}
+
+func HandleGetUserNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ var fields []hotline.Field
+ for _, c := range cc.Server.ClientMgr.List() {
+ b, err := io.ReadAll(&hotline.User{
+ ID: c.ID,
+ Icon: c.Icon,
+ Flags: c.Flags[:],
+ Name: string(c.UserName),
+ })
+ if err != nil {
+ return nil
+ }
+
+ fields = append(fields, hotline.NewField(hotline.FieldUsernameWithInfo, b))
+ }
+
+ return []hotline.Transaction{cc.NewReply(t, fields...)}
+}
+
+func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if t.GetField(hotline.FieldUserName).Data != nil {
+ if cc.Authorize(hotline.AccessAnyName) {
+ cc.UserName = t.GetField(hotline.FieldUserName).Data
+ } else {
+ cc.UserName = []byte(cc.Account.Name)
+ }
+ }
+
+ cc.Icon = t.GetField(hotline.FieldUserIconID).Data
+
+ cc.Logger = cc.Logger.With("Name", string(cc.UserName))
+ cc.Logger.Info("Login successful")
+
+ options := t.GetField(hotline.FieldOptions).Data
+ optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options)))
+
+ // Check refuse private PM option
+
+ cc.FlagsMU.Lock()
+ defer cc.FlagsMU.Unlock()
+ cc.Flags.Set(hotline.UserFlagRefusePM, optBitmap.Bit(hotline.UserOptRefusePM))
+
+ // Check refuse private chat option
+ cc.Flags.Set(hotline.UserFlagRefusePChat, optBitmap.Bit(hotline.UserOptRefuseChat))
+
+ // Check auto response
+ if optBitmap.Bit(hotline.UserOptAutoResponse) == 1 {
+ cc.AutoReply = t.GetField(hotline.FieldAutomaticResponse).Data
+ }
+
+ trans := cc.NotifyOthers(
+ hotline.NewTransaction(
+ hotline.TranNotifyChangeUser, [2]byte{0, 0},
+ hotline.NewField(hotline.FieldUserName, cc.UserName),
+ hotline.NewField(hotline.FieldUserID, cc.ID[:]),
+ hotline.NewField(hotline.FieldUserIconID, cc.Icon),
+ hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]),
+ ),
+ )
+ res = append(res, trans...)
+
+ if cc.Server.Config.BannerFile != "" {
+ res = append(res, hotline.NewTransaction(hotline.TranServerBanner, cc.ID, hotline.NewField(hotline.FieldBannerType, []byte("JPEG"))))
+ }
+
+ res = append(res, cc.NewReply(t))
+
+ return res
+}
+
+// HandleTranOldPostNews updates the flat news
+// Fields used in this request:
+// 101 Data
+func HandleTranOldPostNews(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessNewsPostArt) {
+ return cc.NewErrReply(t, "You are not allowed to post news.")
+ }
+
+ newsDateTemplate := hotline.NewsDateFormat
+ if cc.Server.Config.NewsDateFormat != "" {
+ newsDateTemplate = cc.Server.Config.NewsDateFormat
+ }
+
+ newsTemplate := hotline.NewsTemplate
+ if cc.Server.Config.NewsDelimiter != "" {
+ newsTemplate = cc.Server.Config.NewsDelimiter
+ }
+
+ newsPost := fmt.Sprintf(newsTemplate+"\r", cc.UserName, time.Now().Format(newsDateTemplate), t.GetField(hotline.FieldData).Data)
+ newsPost = strings.ReplaceAll(newsPost, "\n", "\r")
+
+ _, err := cc.Server.MessageBoard.Write([]byte(newsPost))
+ if err != nil {
+ cc.Logger.Error("error writing news post", "err", err)
+ return nil
+ }
+
+ // Notify all clients of updated news
+ cc.SendAll(
+ hotline.TranNewMsg,
+ hotline.NewField(hotline.FieldData, []byte(newsPost)),
+ )
+
+ return append(res, cc.NewReply(t))
+}
+
+func HandleDisconnectUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessDisconUser) {
+ return cc.NewErrReply(t, "You are not allowed to disconnect users.")
+ }
+
+ clientID := [2]byte(t.GetField(hotline.FieldUserID).Data)
+ clientConn := cc.Server.ClientMgr.Get(clientID)
+
+ if clientConn.Authorize(hotline.AccessCannotBeDiscon) {
+ return cc.NewErrReply(t, clientConn.Account.Login+" is not allowed to be disconnected.")
+ }
+
+ // If FieldOptions is set, then the client IP is banned in addition to disconnected.
+ // 00 01 = temporary ban
+ // 00 02 = permanent ban
+ if t.GetField(hotline.FieldOptions).Data != nil {
+ switch t.GetField(hotline.FieldOptions).Data[1] {
+ case 1:
+ // send message: "You are temporarily banned on this server"
+ cc.Logger.Info("Disconnect & temporarily ban " + string(clientConn.UserName))
+
+ res = append(res, hotline.NewTransaction(
+ hotline.TranServerMsg,
+ clientConn.ID,
+ hotline.NewField(hotline.FieldData, []byte("You are temporarily banned on this server")),
+ hotline.NewField(hotline.FieldChatOptions, []byte{0, 0}),
+ ))
+
+ banUntil := time.Now().Add(hotline.BanDuration)
+ ip := strings.Split(clientConn.RemoteAddr, ":")[0]
+
+ err := cc.Server.BanList.Add(ip, &banUntil)
+ if err != nil {
+ cc.Logger.Error("Error saving ban", "err", err)
+ // TODO
+ }
+ case 2:
+ // send message: "You are permanently banned on this server"
+ cc.Logger.Info("Disconnect & ban " + string(clientConn.UserName))
+
+ res = append(res, hotline.NewTransaction(
+ hotline.TranServerMsg,
+ clientConn.ID,
+ hotline.NewField(hotline.FieldData, []byte("You are permanently banned on this server")),
+ hotline.NewField(hotline.FieldChatOptions, []byte{0, 0}),
+ ))
+
+ ip := strings.Split(clientConn.RemoteAddr, ":")[0]
+
+ err := cc.Server.BanList.Add(ip, nil)
+ if err != nil {
+ // TODO
+ }
+ }
+ }
+
+ // TODO: remove this awful hack
+ go func() {
+ time.Sleep(1 * time.Second)
+ clientConn.Disconnect()
+ }()
+
+ return append(res, cc.NewReply(t))
+}
+
+// HandleGetNewsCatNameList returns a list of news categories for a path
+// Fields used in the request:
+// 325 News path (Optional)
+func HandleGetNewsCatNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessNewsReadArt) {
+ return cc.NewErrReply(t, "You are not allowed to read news.")
+ }
+
+ pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
+ if err != nil {
+
+ }
+
+ var fields []hotline.Field
+ for _, cat := range cc.Server.ThreadedNewsMgr.GetCategories(pathStrs) {
+ b, err := io.ReadAll(&cat)
+ if err != nil {
+ // TODO
+ }
+
+ fields = append(fields, hotline.NewField(hotline.FieldNewsCatListData15, b))
+ }
+
+ return append(res, cc.NewReply(t, fields...))
+}
+
+func HandleNewNewsCat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessNewsCreateCat) {
+ return cc.NewErrReply(t, "You are not allowed to create news categories.")
+ }
+
+ name := string(t.GetField(hotline.FieldNewsCatName).Data)
+ pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
+ if err != nil {
+ return res
+ }
+
+ err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, hotline.NewsCategory)
+ if err != nil {
+ cc.Logger.Error("error creating news category", "err", err)
+ }
+
+ return []hotline.Transaction{cc.NewReply(t)}
+}
+
+// Fields used in the request:
+// 322 News category Name
+// 325 News path
+func HandleNewNewsFldr(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessNewsCreateFldr) {
+ return cc.NewErrReply(t, "You are not allowed to create news folders.")
+ }
+
+ name := string(t.GetField(hotline.FieldFileName).Data)
+ pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
+ if err != nil {
+ return res
+ }
+
+ err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, hotline.NewsBundle)
+ if err != nil {
+ cc.Logger.Error("error creating news bundle", "err", err)
+ }
+
+ return append(res, cc.NewReply(t))
+}
+
+// HandleGetNewsArtData gets the list of article names at the specified news path.
+
+// Fields used in the request:
+// 325 News path Optional
+
+// Fields used in the reply:
+// 321 News article list data Optional
+func HandleGetNewsArtNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessNewsReadArt) {
+ return cc.NewErrReply(t, "You are not allowed to read news.")
+ }
+
+ pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
+ if err != nil {
+ return res
+ }
+
+ nald := cc.Server.ThreadedNewsMgr.ListArticles(pathStrs)
+
+ b, err := io.ReadAll(&nald)
+ if err != nil {
+ return res
+ }
+
+ return append(res, cc.NewReply(t, hotline.NewField(hotline.FieldNewsArtListData, b)))
+}
+
+// HandleGetNewsArtData requests information about the specific news article.
+// Fields used in the request:
+//
+// Request fields
+// 325 News path
+// 326 News article Type
+// 327 News article data flavor
+//
+// Fields used in the reply:
+// 328 News article title
+// 329 News article poster
+// 330 News article date
+// 331 Previous article Type
+// 332 Next article Type
+// 335 Parent article Type
+// 336 First child article Type
+// 327 News article data flavor "Should be “text/plain”
+// 333 News article data Optional (if data flavor is “text/plain”)
+func HandleGetNewsArtData(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessNewsReadArt) {
+ return cc.NewErrReply(t, "You are not allowed to read news.")
+ }
+
+ newsPath, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
+ if err != nil {
+ return res
+ }
+
+ convertedID, err := t.GetField(hotline.FieldNewsArtID).DecodeInt()
+ if err != nil {
+ return res
+ }
+
+ art := cc.Server.ThreadedNewsMgr.GetArticle(newsPath, uint32(convertedID))
+ if art == nil {
+ return append(res, cc.NewReply(t))
+ }
+
+ res = append(res, cc.NewReply(t,
+ hotline.NewField(hotline.FieldNewsArtTitle, []byte(art.Title)),
+ hotline.NewField(hotline.FieldNewsArtPoster, []byte(art.Poster)),
+ hotline.NewField(hotline.FieldNewsArtDate, art.Date[:]),
+ hotline.NewField(hotline.FieldNewsArtPrevArt, art.PrevArt[:]),
+ hotline.NewField(hotline.FieldNewsArtNextArt, art.NextArt[:]),
+ hotline.NewField(hotline.FieldNewsArtParentArt, art.ParentArt[:]),
+ hotline.NewField(hotline.FieldNewsArt1stChildArt, art.FirstChildArt[:]),
+ hotline.NewField(hotline.FieldNewsArtDataFlav, []byte("text/plain")),
+ hotline.NewField(hotline.FieldNewsArtData, []byte(art.Data)),
+ ))
+ return res
+}
+
+// HandleDelNewsItem deletes a threaded news folder or category.
+// Fields used in the request:
+// 325 News path
+// Fields used in the reply:
+// None
+func HandleDelNewsItem(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
+ if err != nil {
+ return res
+ }
+
+ item := cc.Server.ThreadedNewsMgr.NewsItem(pathStrs)
+
+ if item.Type == [2]byte{0, 3} {
+ if !cc.Authorize(hotline.AccessNewsDeleteCat) {
+ return cc.NewErrReply(t, "You are not allowed to delete news categories.")
+ }
+ } else {
+ if !cc.Authorize(hotline.AccessNewsDeleteFldr) {
+ return cc.NewErrReply(t, "You are not allowed to delete news folders.")
+ }
+ }
+
+ err = cc.Server.ThreadedNewsMgr.DeleteNewsItem(pathStrs)
+ if err != nil {
+ return res
+ }
+
+ return append(res, cc.NewReply(t))
+}
+
+// HandleDelNewsArt deletes a threaded news article.
+// Request Fields
+// 325 News path
+// 326 News article Type
+// 337 News article recursive delete - Delete child articles (1) or not (0)
+func HandleDelNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessNewsDeleteArt) {
+ return cc.NewErrReply(t, "You are not allowed to delete news articles.")
+
+ }
+
+ pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
+ if err != nil {
+ return res
+ }
+
+ articleID, err := t.GetField(hotline.FieldNewsArtID).DecodeInt()
+ if err != nil {
+ cc.Logger.Error("error reading article Type", "err", err)
+ return
+ }
+
+ deleteRecursive := bytes.Equal([]byte{0, 1}, t.GetField(hotline.FieldNewsArtRecurseDel).Data)
+
+ err = cc.Server.ThreadedNewsMgr.DeleteArticle(pathStrs, uint32(articleID), deleteRecursive)
+ if err != nil {
+ cc.Logger.Error("error deleting news article", "err", err)
+ }
+
+ return []hotline.Transaction{cc.NewReply(t)}
+}
+
+// Request fields
+// 325 News path
+// 326 News article Type Type of the parent article?
+// 328 News article title
+// 334 News article flags
+// 327 News article data flavor Currently “text/plain”
+// 333 News article data
+func HandlePostNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessNewsPostArt) {
+ return cc.NewErrReply(t, "You are not allowed to post news articles.")
+ }
+
+ pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath()
+ if err != nil {
+ return res
+ }
+
+ parentArticleID, err := t.GetField(hotline.FieldNewsArtID).DecodeInt()
+ if err != nil {
+ return res
+ }
+
+ err = cc.Server.ThreadedNewsMgr.PostArticle(
+ pathStrs,
+ uint32(parentArticleID),
+ hotline.NewsArtData{
+ Title: string(t.GetField(hotline.FieldNewsArtTitle).Data),
+ Poster: string(cc.UserName),
+ Date: hotline.NewTime(time.Now()),
+ DataFlav: hotline.NewsFlavor,
+ Data: string(t.GetField(hotline.FieldNewsArtData).Data),
+ },
+ )
+ if err != nil {
+ cc.Logger.Error("error posting news article", "err", err)
+ }
+
+ return append(res, cc.NewReply(t))
+}
+
+// HandleGetMsgs returns the flat news data
+func HandleGetMsgs(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessNewsReadArt) {
+ return cc.NewErrReply(t, "You are not allowed to read news.")
+ }
+
+ _, _ = cc.Server.MessageBoard.Seek(0, 0)
+
+ newsData, err := io.ReadAll(cc.Server.MessageBoard)
+ if err != nil {
+ // TODO
+ }
+
+ return append(res, cc.NewReply(t, hotline.NewField(hotline.FieldData, newsData)))
+}
+
+func HandleDownloadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessDownloadFile) {
+ return cc.NewErrReply(t, "You are not allowed to download files.")
+ }
+
+ fileName := t.GetField(hotline.FieldFileName).Data
+ filePath := t.GetField(hotline.FieldFilePath).Data
+ resumeData := t.GetField(hotline.FieldFileResumeData).Data
+
+ var dataOffset int64
+ var frd hotline.FileResumeData
+ if resumeData != nil {
+ if err := frd.UnmarshalBinary(t.GetField(hotline.FieldFileResumeData).Data); err != nil {
+ return res
+ }
+ // TODO: handle rsrc fork offset
+ dataOffset = int64(binary.BigEndian.Uint32(frd.ForkInfoList[0].DataSize[:]))
+ }
+
+ fullFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, fileName)
+ if err != nil {
+ return res
+ }
+
+ hlFile, err := hotline.NewFileWrapper(cc.Server.FS, fullFilePath, dataOffset)
+ if err != nil {
+ return res
+ }
+
+ xferSize := hlFile.Ffo.TransferSize(0)
+
+ ft := cc.NewFileTransfer(hotline.FileDownload, fileName, filePath, xferSize)
+
+ // TODO: refactor to remove this
+ if resumeData != nil {
+ var frd hotline.FileResumeData
+ if err := frd.UnmarshalBinary(t.GetField(hotline.FieldFileResumeData).Data); err != nil {
+ return res
+ }
+ ft.FileResumeData = &frd
+ }
+
+ // Optional field for when a client requests file preview
+ // Used only for TEXT, JPEG, GIFF, BMP or PICT files
+ // The value will always be 2
+ if t.GetField(hotline.FieldFileTransferOptions).Data != nil {
+ ft.Options = t.GetField(hotline.FieldFileTransferOptions).Data
+ xferSize = hlFile.Ffo.FlatFileDataForkHeader.DataSize[:]
+ }
+
+ res = append(res, cc.NewReply(t,
+ hotline.NewField(hotline.FieldRefNum, ft.RefNum[:]),
+ hotline.NewField(hotline.FieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count
+ hotline.NewField(hotline.FieldTransferSize, xferSize),
+ hotline.NewField(hotline.FieldFileSize, hlFile.Ffo.FlatFileDataForkHeader.DataSize[:]),
+ ))
+
+ return res
+}
+
+// Download all files from the specified folder and sub-folders
+func HandleDownloadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessDownloadFile) {
+ return cc.NewErrReply(t, "You are not allowed to download folders.")
+ }
+
+ fullFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, t.GetField(hotline.FieldFilePath).Data, t.GetField(hotline.FieldFileName).Data)
+ if err != nil {
+ return res
+ }
+
+ transferSize, err := hotline.CalcTotalSize(fullFilePath)
+ if err != nil {
+ return res
+ }
+ itemCount, err := hotline.CalcItemCount(fullFilePath)
+ if err != nil {
+ return res
+ }
+
+ fileTransfer := cc.NewFileTransfer(hotline.FolderDownload, t.GetField(hotline.FieldFileName).Data, t.GetField(hotline.FieldFilePath).Data, transferSize)
+
+ var fp hotline.FilePath
+ _, err = fp.Write(t.GetField(hotline.FieldFilePath).Data)
+ if err != nil {
+ return res
+ }
+
+ res = append(res, cc.NewReply(t,
+ hotline.NewField(hotline.FieldRefNum, fileTransfer.RefNum[:]),
+ hotline.NewField(hotline.FieldTransferSize, transferSize),
+ hotline.NewField(hotline.FieldFolderItemCount, itemCount),
+ hotline.NewField(hotline.FieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count
+ ))
+ return res
+}
+
+// Upload all files from the local folder and its subfolders to the specified path on the server
+// Fields used in the request
+// 201 File Name
+// 202 File path
+// 108 hotline.Transfer size Total size of all items in the folder
+// 220 Folder item count
+// 204 File transfer options "Optional Currently set to 1" (TODO: ??)
+func HandleUploadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ var fp hotline.FilePath
+ if t.GetField(hotline.FieldFilePath).Data != nil {
+ if _, err := fp.Write(t.GetField(hotline.FieldFilePath).Data); err != nil {
+ return res
+ }
+ }
+
+ // Handle special cases for Upload and Drop Box folders
+ if !cc.Authorize(hotline.AccessUploadAnywhere) {
+ if !fp.IsUploadDir() && !fp.IsDropbox() {
+ return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload of the folder \"%v\" because you are only allowed to upload to the \"Uploads\" folder.", string(t.GetField(hotline.FieldFileName).Data)))
+ }
+ }
+
+ fileTransfer := cc.NewFileTransfer(hotline.FolderUpload,
+ t.GetField(hotline.FieldFileName).Data,
+ t.GetField(hotline.FieldFilePath).Data,
+ t.GetField(hotline.FieldTransferSize).Data,
+ )
+
+ fileTransfer.FolderItemCount = t.GetField(hotline.FieldFolderItemCount).Data
+
+ return append(res, cc.NewReply(t, hotline.NewField(hotline.FieldRefNum, fileTransfer.RefNum[:])))
+}
+
+// HandleUploadFile
+// Fields used in the request:
+// 201 File Name
+// 202 File path
+// 204 File transfer options "Optional
+// Used only to resume download, currently has value 2"
+// 108 File transfer size "Optional used if download is not resumed"
+func HandleUploadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessUploadFile) {
+ return cc.NewErrReply(t, "You are not allowed to upload files.")
+ }
+
+ fileName := t.GetField(hotline.FieldFileName).Data
+ filePath := t.GetField(hotline.FieldFilePath).Data
+ transferOptions := t.GetField(hotline.FieldFileTransferOptions).Data
+ transferSize := t.GetField(hotline.FieldTransferSize).Data // not sent for resume
+
+ var fp hotline.FilePath
+ if filePath != nil {
+ if _, err := fp.Write(filePath); err != nil {
+ return res
+ }
+ }
+
+ // Handle special cases for Upload and Drop Box folders
+ if !cc.Authorize(hotline.AccessUploadAnywhere) {
+ if !fp.IsUploadDir() && !fp.IsDropbox() {
+ return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload of the file \"%v\" because you are only allowed to upload to the \"Uploads\" folder.", string(fileName)))
+ }
+ }
+ fullFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, fileName)
+ if err != nil {
+ return res
+ }
+
+ if _, err := cc.Server.FS.Stat(fullFilePath); err == nil {
+ return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload because there is already a file named \"%v\". Try choosing a different Name.", string(fileName)))
+ }
+
+ ft := cc.NewFileTransfer(hotline.FileUpload, fileName, filePath, transferSize)
+
+ replyT := cc.NewReply(t, hotline.NewField(hotline.FieldRefNum, ft.RefNum[:]))
+
+ // client has requested to resume a partially transferred file
+ if transferOptions != nil {
+ fileInfo, err := cc.Server.FS.Stat(fullFilePath + hotline.IncompleteFileSuffix)
+ if err != nil {
+ return res
+ }
+
+ offset := make([]byte, 4)
+ binary.BigEndian.PutUint32(offset, uint32(fileInfo.Size()))
+
+ fileResumeData := hotline.NewFileResumeData([]hotline.ForkInfoList{
+ *hotline.NewForkInfoList(offset),
+ })
+
+ b, _ := fileResumeData.BinaryMarshal()
+
+ ft.TransferSize = offset
+
+ replyT.Fields = append(replyT.Fields, hotline.NewField(hotline.FieldFileResumeData, b))
+ }
+
+ res = append(res, replyT)
+ return res
+}
+
+func HandleSetClientUserInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if len(t.GetField(hotline.FieldUserIconID).Data) == 4 {
+ cc.Icon = t.GetField(hotline.FieldUserIconID).Data[2:]
+ } else {
+ cc.Icon = t.GetField(hotline.FieldUserIconID).Data
+ }
+ if cc.Authorize(hotline.AccessAnyName) {
+ cc.UserName = t.GetField(hotline.FieldUserName).Data
+ }
+
+ // the options field is only passed by the client versions > 1.2.3.
+ options := t.GetField(hotline.FieldOptions).Data
+ if options != nil {
+ optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options)))
+
+ cc.Flags.Set(hotline.UserFlagRefusePM, optBitmap.Bit(hotline.UserOptRefusePM))
+ cc.Flags.Set(hotline.UserFlagRefusePChat, optBitmap.Bit(hotline.UserOptRefuseChat))
+
+ // Check auto response
+ if optBitmap.Bit(hotline.UserOptAutoResponse) == 1 {
+ cc.AutoReply = t.GetField(hotline.FieldAutomaticResponse).Data
+ } else {
+ cc.AutoReply = []byte{}
+ }
+ }
+
+ for _, c := range cc.Server.ClientMgr.List() {
+ res = append(res, hotline.NewTransaction(
+ hotline.TranNotifyChangeUser,
+ c.ID,
+ hotline.NewField(hotline.FieldUserID, cc.ID[:]),
+ hotline.NewField(hotline.FieldUserIconID, cc.Icon),
+ hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]),
+ hotline.NewField(hotline.FieldUserName, cc.UserName),
+ ))
+ }
+
+ return res
+}
+
+// HandleKeepAlive responds to keepalive transactions with an empty reply
+// * HL 1.9.2 Client sends keepalive msg every 3 minutes
+// * HL 1.2.3 Client doesn't send keepalives
+func HandleKeepAlive(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ res = append(res, cc.NewReply(t))
+
+ return res
+}
+
+func HandleGetFileNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ fullPath, err := hotline.ReadPath(
+ cc.Server.Config.FileRoot,
+ t.GetField(hotline.FieldFilePath).Data,
+ nil,
+ )
+ if err != nil {
+ return res
+ }
+
+ var fp hotline.FilePath
+ if t.GetField(hotline.FieldFilePath).Data != nil {
+ if _, err = fp.Write(t.GetField(hotline.FieldFilePath).Data); err != nil {
+ return res
+ }
+ }
+
+ // Handle special case for drop box folders
+ if fp.IsDropbox() && !cc.Authorize(hotline.AccessViewDropBoxes) {
+ return cc.NewErrReply(t, "You are not allowed to view drop boxes.")
+ }
+
+ fileNames, err := hotline.GetFileNameList(fullPath, cc.Server.Config.IgnoreFiles)
+ if err != nil {
+ return res
+ }
+
+ res = append(res, cc.NewReply(t, fileNames...))
+
+ return res
+}
+
+// =================================
+// Hotline private chat flow
+// =================================
+// 1. ClientA sends TranInviteNewChat to server with user Type to invite
+// 2. Server creates new ChatID
+// 3. Server sends TranInviteToChat to invitee
+// 4. Server replies to ClientA with new Chat Type
+//
+// A dialog box pops up in the invitee client with options to accept or decline the invitation.
+// If Accepted is clicked:
+// 1. ClientB sends TranJoinChat with FieldChatID
+
+// HandleInviteNewChat invites users to new private chat
+func HandleInviteNewChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessOpenChat) {
+ return cc.NewErrReply(t, "You are not allowed to request private chat.")
+ }
+
+ // Client to Invite
+ targetID := t.GetField(hotline.FieldUserID).Data
+
+ // Create a new chat with self as initial member.
+ newChatID := cc.Server.ChatMgr.New(cc)
+
+ // Check if target user has "Refuse private chat" flag
+ targetClient := cc.Server.ClientMgr.Get([2]byte(targetID))
+ flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(targetClient.Flags[:])))
+ if flagBitmap.Bit(hotline.UserFlagRefusePChat) == 1 {
+ res = append(res,
+ hotline.NewTransaction(
+ hotline.TranServerMsg,
+ cc.ID,
+ hotline.NewField(hotline.FieldData, []byte(string(targetClient.UserName)+" does not accept private chats.")),
+ hotline.NewField(hotline.FieldUserName, targetClient.UserName),
+ hotline.NewField(hotline.FieldUserID, targetClient.ID[:]),
+ hotline.NewField(hotline.FieldOptions, []byte{0, 2}),
+ ),
+ )
+ } else {
+ res = append(res,
+ hotline.NewTransaction(
+ hotline.TranInviteToChat,
+ [2]byte(targetID),
+ hotline.NewField(hotline.FieldChatID, newChatID[:]),
+ hotline.NewField(hotline.FieldUserName, cc.UserName),
+ hotline.NewField(hotline.FieldUserID, cc.ID[:]),
+ ),
+ )
+ }
+
+ return append(
+ res,
+ cc.NewReply(t,
+ hotline.NewField(hotline.FieldChatID, newChatID[:]),
+ hotline.NewField(hotline.FieldUserName, cc.UserName),
+ hotline.NewField(hotline.FieldUserID, cc.ID[:]),
+ hotline.NewField(hotline.FieldUserIconID, cc.Icon),
+ hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]),
+ ),
+ )
+}
+
+func HandleInviteToChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessOpenChat) {
+ return cc.NewErrReply(t, "You are not allowed to request private chat.")
+ }
+
+ // Client to Invite
+ targetID := t.GetField(hotline.FieldUserID).Data
+ chatID := t.GetField(hotline.FieldChatID).Data
+
+ return []hotline.Transaction{
+ hotline.NewTransaction(
+ hotline.TranInviteToChat,
+ [2]byte(targetID),
+ hotline.NewField(hotline.FieldChatID, chatID),
+ hotline.NewField(hotline.FieldUserName, cc.UserName),
+ hotline.NewField(hotline.FieldUserID, cc.ID[:]),
+ ),
+ cc.NewReply(
+ t,
+ hotline.NewField(hotline.FieldChatID, chatID),
+ hotline.NewField(hotline.FieldUserName, cc.UserName),
+ hotline.NewField(hotline.FieldUserID, cc.ID[:]),
+ hotline.NewField(hotline.FieldUserIconID, cc.Icon),
+ hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]),
+ ),
+ }
+}
+
+func HandleRejectChatInvite(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ chatID := [4]byte(t.GetField(hotline.FieldChatID).Data)
+
+ for _, c := range cc.Server.ChatMgr.Members(chatID) {
+ res = append(res,
+ hotline.NewTransaction(
+ hotline.TranChatMsg,
+ c.ID,
+ hotline.NewField(hotline.FieldChatID, chatID[:]),
+ hotline.NewField(hotline.FieldData, append(cc.UserName, []byte(" declined invitation to chat")...)),
+ ),
+ )
+ }
+
+ return res
+}
+
+// HandleJoinChat is sent from a v1.8+ Hotline client when the joins a private chat
+// Fields used in the reply:
+// * 115 Chat subject
+// * 300 User Name with info (Optional)
+// * 300 (more user names with info)
+func HandleJoinChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ chatID := t.GetField(hotline.FieldChatID).Data
+
+ // Send TranNotifyChatChangeUser to current members of the chat to inform of new user
+ for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) {
+ res = append(res,
+ hotline.NewTransaction(
+ hotline.TranNotifyChatChangeUser,
+ c.ID,
+ hotline.NewField(hotline.FieldChatID, chatID),
+ hotline.NewField(hotline.FieldUserName, cc.UserName),
+ hotline.NewField(hotline.FieldUserID, cc.ID[:]),
+ hotline.NewField(hotline.FieldUserIconID, cc.Icon),
+ hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]),
+ ),
+ )
+ }
+
+ cc.Server.ChatMgr.Join(hotline.ChatID(chatID), cc)
+
+ subject := cc.Server.ChatMgr.GetSubject(hotline.ChatID(chatID))
+
+ replyFields := []hotline.Field{hotline.NewField(hotline.FieldChatSubject, []byte(subject))}
+ for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) {
+ b, err := io.ReadAll(&hotline.User{
+ ID: c.ID,
+ Icon: c.Icon,
+ Flags: c.Flags[:],
+ Name: string(c.UserName),
+ })
+ if err != nil {
+ return res
+ }
+ replyFields = append(replyFields, hotline.NewField(hotline.FieldUsernameWithInfo, b))
+ }
+
+ return append(res, cc.NewReply(t, replyFields...))
+}
+
+// HandleLeaveChat is sent from a v1.8+ Hotline client when the user exits a private chat
+// Fields used in the request:
+// - 114 FieldChatID
+//
+// Reply is not expected.
+func HandleLeaveChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ chatID := t.GetField(hotline.FieldChatID).Data
+
+ cc.Server.ChatMgr.Leave([4]byte(chatID), cc.ID)
+
+ // Notify members of the private chat that the user has left
+ for _, c := range cc.Server.ChatMgr.Members(hotline.ChatID(chatID)) {
+ res = append(res,
+ hotline.NewTransaction(
+ hotline.TranNotifyChatDeleteUser,
+ c.ID,
+ hotline.NewField(hotline.FieldChatID, chatID),
+ hotline.NewField(hotline.FieldUserID, cc.ID[:]),
+ ),
+ )
+ }
+
+ return res
+}
+
+// HandleSetChatSubject is sent from a v1.8+ Hotline client when the user sets a private chat subject
+// Fields used in the request:
+// * 114 Chat Type
+// * 115 Chat subject
+// Reply is not expected.
+func HandleSetChatSubject(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ chatID := t.GetField(hotline.FieldChatID).Data
+
+ cc.Server.ChatMgr.SetSubject([4]byte(chatID), string(t.GetField(hotline.FieldChatSubject).Data))
+
+ // Notify chat members of new subject.
+ for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) {
+ res = append(res,
+ hotline.NewTransaction(
+ hotline.TranNotifyChatSubject,
+ c.ID,
+ hotline.NewField(hotline.FieldChatID, chatID),
+ hotline.NewField(hotline.FieldChatSubject, t.GetField(hotline.FieldChatSubject).Data),
+ ),
+ )
+ }
+
+ return res
+}
+
+// HandleMakeAlias makes a file alias using the specified path.
+// Fields used in the request:
+// 201 File Name
+// 202 File path
+// 212 File new path Destination path
+//
+// Fields used in the reply:
+// None
+func HandleMakeAlias(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ if !cc.Authorize(hotline.AccessMakeAlias) {
+ return cc.NewErrReply(t, "You are not allowed to make aliases.")
+ }
+ fileName := t.GetField(hotline.FieldFileName).Data
+ filePath := t.GetField(hotline.FieldFilePath).Data
+ fileNewPath := t.GetField(hotline.FieldFileNewPath).Data
+
+ fullFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, filePath, fileName)
+ if err != nil {
+ return res
+ }
+
+ fullNewFilePath, err := hotline.ReadPath(cc.Server.Config.FileRoot, fileNewPath, fileName)
+ if err != nil {
+ return res
+ }
+
+ cc.Logger.Debug("Make alias", "src", fullFilePath, "dst", fullNewFilePath)
+
+ if err := cc.Server.FS.Symlink(fullFilePath, fullNewFilePath); err != nil {
+ return cc.NewErrReply(t, "Error creating alias")
+ }
+
+ res = append(res, cc.NewReply(t))
+ return res
+}
+
+// HandleDownloadBanner handles requests for a new banner from the server
+// Fields used in the request:
+// None
+// Fields used in the reply:
+// 107 FieldRefNum Used later for transfer
+// 108 FieldTransferSize Size of data to be downloaded
+func HandleDownloadBanner(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) {
+ ft := cc.NewFileTransfer(hotline.BannerDownload, []byte{}, []byte{}, make([]byte, 4))
+ binary.BigEndian.PutUint32(ft.TransferSize, uint32(len(cc.Server.Banner)))
+
+ return append(res, cc.NewReply(t,
+ hotline.NewField(hotline.FieldRefNum, ft.RefNum[:]),
+ hotline.NewField(hotline.FieldTransferSize, ft.TransferSize),
+ ))
+}
--- /dev/null
+package mobius
+
+import (
+ "cmp"
+ "encoding/binary"
+ "encoding/hex"
+ "errors"
+ "github.com/jhalter/mobius/hotline"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "io"
+ "io/fs"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+)
+
+type mockReadWriteSeeker struct {
+ mock.Mock
+}
+
+func (m *mockReadWriteSeeker) Read(p []byte) (int, error) {
+ args := m.Called(p)
+
+ return args.Int(0), args.Error(1)
+}
+
+func (m *mockReadWriteSeeker) Write(p []byte) (int, error) {
+ args := m.Called(p)
+
+ return args.Int(0), args.Error(1)
+}
+
+func (m *mockReadWriteSeeker) Seek(offset int64, whence int) (int64, error) {
+ args := m.Called(offset, whence)
+
+ return args.Get(0).(int64), args.Error(1)
+}
+
+func NewTestLogger() *slog.Logger {
+ return slog.New(slog.NewTextHandler(os.Stdout, nil))
+}
+
+// assertTransferBytesEqual takes a string with a hexdump in the same format that `hexdump -C` produces and compares with
+// a hexdump for the bytes in got, after stripping the create/modify timestamps.
+// I don't love this, but as git does not preserve file create/modify timestamps, we either need to fully mock the
+// filesystem interactions or work around in this way.
+// TODO: figure out a better solution
+func assertTransferBytesEqual(t *testing.T, wantHexDump string, got []byte) bool {
+ if wantHexDump == "" {
+ return true
+ }
+
+ clean := slices.Concat(
+ got[:92],
+ make([]byte, 16),
+ got[108:],
+ )
+ return assert.Equal(t, wantHexDump, hex.Dump(clean))
+}
+
+var tranSortFunc = func(a, b hotline.Transaction) int {
+ return cmp.Compare(
+ binary.BigEndian.Uint16(a.ClientID[:]),
+ binary.BigEndian.Uint16(b.ClientID[:]),
+ )
+}
+
+// TranAssertEqual compares equality of transactions slices after stripping out the random transaction Type
+func TranAssertEqual(t *testing.T, tran1, tran2 []hotline.Transaction) bool {
+ var newT1 []hotline.Transaction
+ var newT2 []hotline.Transaction
+
+ for _, trans := range tran1 {
+ trans.ID = [4]byte{0, 0, 0, 0}
+ var fs []hotline.Field
+ for _, field := range trans.Fields {
+ if field.Type == hotline.FieldRefNum { // FieldRefNum
+ continue
+ }
+ if field.Type == hotline.FieldChatID { // FieldChatID
+ continue
+ }
+
+ fs = append(fs, field)
+ }
+ trans.Fields = fs
+ newT1 = append(newT1, trans)
+ }
+
+ for _, trans := range tran2 {
+ trans.ID = [4]byte{0, 0, 0, 0}
+ var fs []hotline.Field
+ for _, field := range trans.Fields {
+ if field.Type == hotline.FieldRefNum { // FieldRefNum
+ continue
+ }
+ if field.Type == hotline.FieldChatID { // FieldChatID
+ continue
+ }
+
+ fs = append(fs, field)
+ }
+ trans.Fields = fs
+ newT2 = append(newT2, trans)
+ }
+
+ slices.SortFunc(newT1, tranSortFunc)
+ slices.SortFunc(newT2, tranSortFunc)
+
+ return assert.Equal(t, newT1, newT2)
+}
+
+func TestHandleSetChatSubject(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ want []hotline.Transaction
+ }{
+ {
+ name: "sends chat subject to private chat members",
+ args: args{
+ cc: &hotline.ClientConn{
+ UserName: []byte{0x00, 0x01},
+ Server: &hotline.Server{
+ ChatMgr: func() *hotline.MockChatManager {
+ m := hotline.MockChatManager{}
+ m.On("Members", hotline.ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*hotline.ClientConn{
+ {
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 1},
+ },
+ {
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 2},
+ },
+ })
+ m.On("SetSubject", hotline.ChatID{0x0, 0x0, 0x0, 0x1}, "Test Subject")
+ return &m
+ }(),
+ //PrivateChats: map[[4]byte]*PrivateChat{
+ // [4]byte{0, 0, 0, 1}: {
+ // Subject: "unset",
+ // ClientConn: map[[2]byte]*ClientConn{
+ // [2]byte{0, 1}: {
+ // Account: &hotline.Account{
+ // Access: AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ // },
+ // ID: [2]byte{0, 1},
+ // },
+ // [2]byte{0, 2}: {
+ // Account: &hotline.Account{
+ // Access: AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ // },
+ // ID: [2]byte{0, 2},
+ // },
+ // },
+ // },
+ //},
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 1},
+ },
+ {
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 2},
+ },
+ },
+ )
+ return &m
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Type: [2]byte{0, 0x6a},
+ ID: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}),
+ hotline.NewField(hotline.FieldChatSubject, []byte("Test Subject")),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0, 0x77},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}),
+ hotline.NewField(hotline.FieldChatSubject, []byte("Test Subject")),
+ },
+ },
+ {
+ ClientID: [2]byte{0, 2},
+ Type: [2]byte{0, 0x77},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}),
+ hotline.NewField(hotline.FieldChatSubject, []byte("Test Subject")),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := HandleSetChatSubject(tt.args.cc, &tt.args.t)
+ if !TranAssertEqual(t, tt.want, got) {
+ t.Errorf("HandleSetChatSubject() got = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestHandleLeaveChat(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ want []hotline.Transaction
+ }{
+ {
+ name: "when client 2 leaves chat",
+ args: args{
+ cc: &hotline.ClientConn{
+ ID: [2]byte{0, 2},
+ Server: &hotline.Server{
+ ChatMgr: func() *hotline.MockChatManager {
+ m := hotline.MockChatManager{}
+ m.On("Members", hotline.ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*hotline.ClientConn{
+ {
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 1},
+ },
+ })
+ m.On("Leave", hotline.ChatID{0x0, 0x0, 0x0, 0x1}, [2]uint8{0x0, 0x2})
+ m.On("GetSubject").Return("unset")
+ return &m
+ }(),
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("Get").Return([]*hotline.ClientConn{
+ {
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 1},
+ },
+ {
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 2},
+ },
+ },
+ )
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(hotline.TranDeleteUser, [2]byte{}, hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1})),
+ },
+ want: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0, 0x76},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 2}),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := HandleLeaveChat(tt.args.cc, &tt.args.t)
+ if !TranAssertEqual(t, tt.want, got) {
+ t.Errorf("HandleLeaveChat() got = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestHandleGetUserNameList(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ want []hotline.Transaction
+ }{
+ {
+ name: "replies with userlist transaction",
+ args: args{
+ cc: &hotline.ClientConn{
+ ID: [2]byte{0, 1},
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ ID: [2]byte{0, 1},
+ Icon: []byte{0, 2},
+ Flags: [2]byte{0, 3},
+ UserName: []byte{0, 4},
+ },
+ {
+ ID: [2]byte{0, 2},
+ Icon: []byte{0, 2},
+ Flags: [2]byte{0, 3},
+ UserName: []byte{0, 4},
+ },
+ },
+ )
+ return &m
+ }(),
+ },
+ },
+ t: hotline.Transaction{},
+ },
+ want: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ Fields: []hotline.Field{
+ hotline.NewField(
+ hotline.FieldUsernameWithInfo,
+ []byte{00, 01, 00, 02, 00, 03, 00, 02, 00, 04},
+ ),
+ hotline.NewField(
+ hotline.FieldUsernameWithInfo,
+ []byte{00, 02, 00, 02, 00, 03, 00, 02, 00, 04},
+ ),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := HandleGetUserNameList(tt.args.cc, &tt.args.t)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestHandleChatSend(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ want []hotline.Transaction
+ }{
+ {
+ name: "sends chat msg transaction to all clients",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessSendChat)
+ return bits
+ }(),
+ },
+ UserName: []byte{0x00, 0x01},
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 1},
+ },
+ {
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 2},
+ },
+ },
+ )
+ return &m
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte("hai")),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Flags: 0x00,
+ IsReply: 0x00,
+ Type: [2]byte{0, 0x6a},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
+ },
+ },
+ {
+ ClientID: [2]byte{0, 2},
+ Flags: 0x00,
+ IsReply: 0x00,
+ Type: [2]byte{0, 0x6a},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
+ },
+ },
+ },
+ },
+ {
+ name: "treats Chat Type 00 00 00 00 as a public chat message",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessSendChat)
+ return bits
+ }(),
+ },
+ UserName: []byte{0x00, 0x01},
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 1},
+ },
+ {
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 2},
+ },
+ },
+ )
+ return &m
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte("hai")),
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 0}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0, 0x6a},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
+ },
+ },
+ {
+ ClientID: [2]byte{0, 2},
+ Type: [2]byte{0, 0x6a},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
+ },
+ },
+ },
+ },
+ {
+ name: "when user does not have required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ //Accounts: map[string]*Account{},
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranChatSend, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldData, []byte("hai")),
+ ),
+ },
+ want: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to participate in chat.")),
+ },
+ },
+ },
+ },
+ {
+ name: "sends chat msg as emote if FieldChatOptions is set to 1",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessSendChat)
+ return bits
+ }(),
+ },
+ UserName: []byte("Testy McTest"),
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 1},
+ },
+ {
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 2},
+ },
+ },
+ )
+ return &m
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte("performed action")),
+ hotline.NewField(hotline.FieldChatOptions, []byte{0x00, 0x01}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Flags: 0x00,
+ IsReply: 0x00,
+ Type: [2]byte{0, 0x6a},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte("\r*** Testy McTest performed action")),
+ },
+ },
+ {
+ ClientID: [2]byte{0, 2},
+ Flags: 0x00,
+ IsReply: 0x00,
+ Type: [2]byte{0, 0x6a},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte("\r*** Testy McTest performed action")),
+ },
+ },
+ },
+ },
+ {
+ name: "does not send chat msg as emote if FieldChatOptions is set to 0",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessSendChat)
+ return bits
+ }(),
+ },
+ UserName: []byte("Testy McTest"),
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 1},
+ },
+ {
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 2},
+ },
+ },
+ )
+ return &m
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte("hello")),
+ hotline.NewField(hotline.FieldChatOptions, []byte{0x00, 0x00}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0, 0x6a},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte("\r Testy McTest: hello")),
+ },
+ },
+ {
+ ClientID: [2]byte{0, 2},
+ Type: [2]byte{0, 0x6a},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte("\r Testy McTest: hello")),
+ },
+ },
+ },
+ },
+ {
+ name: "only sends chat msg to clients with AccessReadChat permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessSendChat)
+ return bits
+ }(),
+ },
+ UserName: []byte{0x00, 0x01},
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessReadChat)
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ },
+ {
+ Account: &hotline.Account{},
+ ID: [2]byte{0, 2},
+ },
+ },
+ )
+ return &m
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte("hai")),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0, 0x6a},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
+ },
+ },
+ },
+ },
+ {
+ name: "only sends private chat msg to members of private chat",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessSendChat)
+ return bits
+ }(),
+ },
+ UserName: []byte{0x00, 0x01},
+ Server: &hotline.Server{
+ ChatMgr: func() *hotline.MockChatManager {
+ m := hotline.MockChatManager{}
+ m.On("Members", hotline.ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*hotline.ClientConn{
+ {
+ ID: [2]byte{0, 1},
+ },
+ {
+ ID: [2]byte{0, 2},
+ },
+ })
+ m.On("GetSubject").Return("unset")
+ return &m
+ }(),
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 1},
+ },
+ {
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{0, 0, 0, 0, 0, 0, 0, 0},
+ },
+ ID: [2]byte{0, 2},
+ },
+ {
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{0, 0, 0, 0, 0, 0, 0, 0},
+ },
+ ID: [2]byte{0, 3},
+ },
+ },
+ )
+ return &m
+ }(),
+ },
+ },
+ t: hotline.Transaction{
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte("hai")),
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}),
+ },
+ },
+ },
+ want: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0, 0x6a},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}),
+ hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
+ },
+ },
+ {
+ ClientID: [2]byte{0, 2},
+ Type: [2]byte{0, 0x6a},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}),
+ hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := HandleChatSend(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.want, got)
+ })
+ }
+}
+
+func TestHandleGetFileInfo(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "returns expected fields when a valid file is requested",
+ args: args{
+ cc: &hotline.ClientConn{
+ ID: [2]byte{0x00, 0x01},
+ Server: &hotline.Server{
+ FS: &hotline.OSFileStore{},
+ Config: hotline.Config{
+ FileRoot: func() string {
+ path, _ := os.Getwd()
+ return filepath.Join(path, "/test/config/Files")
+ }(),
+ },
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranGetFileInfo, [2]byte{},
+ hotline.NewField(hotline.FieldFileName, []byte("testfile.txt")),
+ hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x00}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ Type: [2]byte{0, 0},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldFileName, []byte("testfile.txt")),
+ hotline.NewField(hotline.FieldFileTypeString, []byte("Text File")),
+ hotline.NewField(hotline.FieldFileCreatorString, []byte("ttxt")),
+ hotline.NewField(hotline.FieldFileType, []byte("TEXT")),
+ hotline.NewField(hotline.FieldFileCreateDate, make([]byte, 8)),
+ hotline.NewField(hotline.FieldFileModifyDate, make([]byte, 8)),
+ hotline.NewField(hotline.FieldFileSize, []byte{0x0, 0x0, 0x0, 0x17}),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleGetFileInfo(tt.args.cc, &tt.args.t)
+
+ // Clear the file timestamp fields to work around problems running the tests in multiple timezones
+ // TODO: revisit how to test this by mocking the stat calls
+ gotRes[0].Fields[4].Data = make([]byte, 8)
+ gotRes[0].Fields[5].Data = make([]byte, 8)
+
+ if !TranAssertEqual(t, tt.wantRes, gotRes) {
+ t.Errorf("HandleGetFileInfo() gotRes = %v, want %v", gotRes, tt.wantRes)
+ }
+ })
+ }
+}
+
+func TestHandleNewFolder(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "without required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranNewFolder,
+ [2]byte{0, 0},
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to create folders.")),
+ },
+ },
+ },
+ },
+ {
+ name: "when path is nested",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessCreateFolder)
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ Server: &hotline.Server{
+ Config: hotline.Config{
+ FileRoot: "/Files/",
+ },
+ FS: func() *hotline.MockFileStore {
+ mfs := &hotline.MockFileStore{}
+ mfs.On("Mkdir", "/Files/aaa/testFolder", fs.FileMode(0777)).Return(nil)
+ mfs.On("Stat", "/Files/aaa/testFolder").Return(nil, os.ErrNotExist)
+ return mfs
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranNewFolder, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testFolder")),
+ hotline.NewField(hotline.FieldFilePath, []byte{
+ 0x00, 0x01,
+ 0x00, 0x00,
+ 0x03,
+ 0x61, 0x61, 0x61,
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ },
+ },
+ },
+ {
+ name: "when path is not nested",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessCreateFolder)
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ Server: &hotline.Server{
+ Config: hotline.Config{
+ FileRoot: "/Files",
+ },
+ FS: func() *hotline.MockFileStore {
+ mfs := &hotline.MockFileStore{}
+ mfs.On("Mkdir", "/Files/testFolder", fs.FileMode(0777)).Return(nil)
+ mfs.On("Stat", "/Files/testFolder").Return(nil, os.ErrNotExist)
+ return mfs
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranNewFolder, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testFolder")),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ },
+ },
+ },
+ {
+ name: "when Write returns an err",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessCreateFolder)
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ Server: &hotline.Server{
+ Config: hotline.Config{
+ FileRoot: "/Files/",
+ },
+ FS: func() *hotline.MockFileStore {
+ mfs := &hotline.MockFileStore{}
+ mfs.On("Mkdir", "/Files/aaa/testFolder", fs.FileMode(0777)).Return(nil)
+ mfs.On("Stat", "/Files/aaa/testFolder").Return(nil, os.ErrNotExist)
+ return mfs
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranNewFolder, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testFolder")),
+ hotline.NewField(hotline.FieldFilePath, []byte{
+ 0x00,
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{},
+ },
+ {
+ name: "FieldFileName does not allow directory traversal",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessCreateFolder)
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ Server: &hotline.Server{
+ Config: hotline.Config{
+ FileRoot: "/Files/",
+ },
+ FS: func() *hotline.MockFileStore {
+ mfs := &hotline.MockFileStore{}
+ mfs.On("Mkdir", "/Files/testFolder", fs.FileMode(0777)).Return(nil)
+ mfs.On("Stat", "/Files/testFolder").Return(nil, os.ErrNotExist)
+ return mfs
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranNewFolder, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("../../testFolder")),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ },
+ },
+ },
+ {
+ name: "FieldFilePath does not allow directory traversal",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessCreateFolder)
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ Server: &hotline.Server{
+ Config: hotline.Config{
+ FileRoot: "/Files/",
+ },
+ FS: func() *hotline.MockFileStore {
+ mfs := &hotline.MockFileStore{}
+ mfs.On("Mkdir", "/Files/foo/testFolder", fs.FileMode(0777)).Return(nil)
+ mfs.On("Stat", "/Files/foo/testFolder").Return(nil, os.ErrNotExist)
+ return mfs
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranNewFolder, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testFolder")),
+ hotline.NewField(hotline.FieldFilePath, []byte{
+ 0x00, 0x02,
+ 0x00, 0x00,
+ 0x03,
+ 0x2e, 0x2e, 0x2f,
+ 0x00, 0x00,
+ 0x03,
+ 0x66, 0x6f, 0x6f,
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleNewFolder(tt.args.cc, &tt.args.t)
+
+ if !TranAssertEqual(t, tt.wantRes, gotRes) {
+ t.Errorf("HandleNewFolder() gotRes = %v, want %v", gotRes, tt.wantRes)
+ }
+ })
+ }
+}
+
+func TestHandleUploadFile(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "when request is valid and user has Upload Anywhere permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Server: &hotline.Server{
+ FS: &hotline.OSFileStore{},
+ FileTransferMgr: hotline.NewMemFileTransferMgr(),
+ Config: hotline.Config{
+ FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(),
+ }},
+ ClientFileTransferMgr: hotline.NewClientFileTransferMgr(),
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessUploadFile)
+ bits.Set(hotline.AccessUploadAnywhere)
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranUploadFile, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testFile")),
+ hotline.NewField(hotline.FieldFilePath, []byte{
+ 0x00, 0x01,
+ 0x00, 0x00,
+ 0x03,
+ 0x2e, 0x2e, 0x2f,
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}), // rand.Seed(1)
+ },
+ },
+ },
+ },
+ {
+ name: "when user does not have required access",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranUploadFile, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testFile")),
+ hotline.NewField(hotline.FieldFilePath, []byte{
+ 0x00, 0x01,
+ 0x00, 0x00,
+ 0x03,
+ 0x2e, 0x2e, 0x2f,
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to upload files.")), // rand.Seed(1)
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleUploadFile(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleMakeAlias(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "with valid input and required permissions",
+ args: args{
+ cc: &hotline.ClientConn{
+ Logger: NewTestLogger(),
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessMakeAlias)
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ Config: hotline.Config{
+ FileRoot: func() string {
+ path, _ := os.Getwd()
+ return path + "/test/config/Files"
+ }(),
+ },
+ Logger: NewTestLogger(),
+ FS: func() *hotline.MockFileStore {
+ mfs := &hotline.MockFileStore{}
+ path, _ := os.Getwd()
+ mfs.On(
+ "Symlink",
+ path+"/test/config/Files/foo/testFile",
+ path+"/test/config/Files/bar/testFile",
+ ).Return(nil)
+ return mfs
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranMakeFileAlias, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testFile")),
+ hotline.NewField(hotline.FieldFilePath, hotline.EncodeFilePath(strings.Join([]string{"foo"}, "/"))),
+ hotline.NewField(hotline.FieldFileNewPath, hotline.EncodeFilePath(strings.Join([]string{"bar"}, "/"))),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ Fields: []hotline.Field(nil),
+ },
+ },
+ },
+ {
+ name: "when symlink returns an error",
+ args: args{
+ cc: &hotline.ClientConn{
+ Logger: NewTestLogger(),
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessMakeAlias)
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ Config: hotline.Config{
+ FileRoot: func() string {
+ path, _ := os.Getwd()
+ return path + "/test/config/Files"
+ }(),
+ },
+ Logger: NewTestLogger(),
+ FS: func() *hotline.MockFileStore {
+ mfs := &hotline.MockFileStore{}
+ path, _ := os.Getwd()
+ mfs.On(
+ "Symlink",
+ path+"/test/config/Files/foo/testFile",
+ path+"/test/config/Files/bar/testFile",
+ ).Return(errors.New("ohno"))
+ return mfs
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranMakeFileAlias, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testFile")),
+ hotline.NewField(hotline.FieldFilePath, hotline.EncodeFilePath(strings.Join([]string{"foo"}, "/"))),
+ hotline.NewField(hotline.FieldFileNewPath, hotline.EncodeFilePath(strings.Join([]string{"bar"}, "/"))),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("Error creating alias")),
+ },
+ },
+ },
+ },
+ {
+ name: "when user does not have required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Logger: NewTestLogger(),
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{},
+ },
+ Server: &hotline.Server{
+ Config: hotline.Config{
+ FileRoot: func() string {
+ path, _ := os.Getwd()
+ return path + "/test/config/Files"
+ }(),
+ },
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranMakeFileAlias, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testFile")),
+ hotline.NewField(hotline.FieldFilePath, []byte{
+ 0x00, 0x01,
+ 0x00, 0x00,
+ 0x03,
+ 0x2e, 0x2e, 0x2e,
+ }),
+ hotline.NewField(hotline.FieldFileNewPath, []byte{
+ 0x00, 0x01,
+ 0x00, 0x00,
+ 0x03,
+ 0x2e, 0x2e, 0x2e,
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to make aliases.")),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleMakeAlias(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleGetUser(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "when account is valid",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessOpenUser)
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Get", "guest").Return(&hotline.Account{
+ Login: "guest",
+ Name: "Guest",
+ Password: "password",
+ Access: hotline.AccessBitmap{},
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranGetUser, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldUserLogin, []byte("guest")),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserName, []byte("Guest")),
+ hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("guest"))),
+ hotline.NewField(hotline.FieldUserPassword, []byte("password")),
+ hotline.NewField(hotline.FieldUserAccess, []byte{0, 0, 0, 0, 0, 0, 0, 0}),
+ },
+ },
+ },
+ },
+ {
+ name: "when user does not have required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ //Accounts: map[string]*Account{},
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranGetUser, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldUserLogin, []byte("nonExistentUser")),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to view accounts.")),
+ },
+ },
+ },
+ },
+ {
+ name: "when account does not exist",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessOpenUser)
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Get", "nonExistentUser").Return((*hotline.Account)(nil))
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranGetUser, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldUserLogin, []byte("nonExistentUser")),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ Flags: 0x00,
+ IsReply: 0x01,
+ Type: [2]byte{0, 0},
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("Account does not exist.")),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleGetUser(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleDeleteUser(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "when user exists",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessDeleteUser)
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Delete", "testuser").Return(nil)
+ return &m
+ }(),
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{}) // TODO
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranDeleteUser, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ Flags: 0x00,
+ IsReply: 0x01,
+ Type: [2]byte{0, 0},
+ Fields: []hotline.Field(nil),
+ },
+ },
+ },
+ {
+ name: "when user does not have required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{},
+ },
+ Server: &hotline.Server{
+ //Accounts: map[string]*Account{},
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranDeleteUser, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete accounts.")),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleDeleteUser(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleGetMsgs(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "returns news data",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessNewsReadArt)
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ MessageBoard: func() *mockReadWriteSeeker {
+ m := mockReadWriteSeeker{}
+ m.On("Seek", int64(0), 0).Return(int64(0), nil)
+ m.On("Read", mock.AnythingOfType("[]uint8")).Run(func(args mock.Arguments) {
+ arg := args.Get(0).([]uint8)
+ copy(arg, "TEST")
+ }).Return(4, io.EOF)
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranGetMsgs, [2]byte{0, 1},
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte("TEST")),
+ },
+ },
+ },
+ },
+ {
+ name: "when user does not have required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{},
+ },
+ Server: &hotline.Server{
+ //Accounts: map[string]*Account{},
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranGetMsgs, [2]byte{0, 1},
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to read news.")),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleGetMsgs(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleNewUser(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "when user does not have required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ //Accounts: map[string]*Account{},
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranNewUser, [2]byte{0, 1},
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to create new accounts.")),
+ },
+ },
+ },
+ },
+ {
+ name: "when user attempts to create account with greater access",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessCreateUser)
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Get", "userB").Return((*hotline.Account)(nil))
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranNewUser, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("userB"))),
+ hotline.NewField(
+ hotline.FieldUserAccess,
+ func() []byte {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessDisconUser)
+ return bits[:]
+ }(),
+ ),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("Cannot create account with more access than yourself.")),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleNewUser(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleListUsers(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "when user does not have required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ //Accounts: map[string]*Account{},
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranNewUser, [2]byte{0, 1},
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to view accounts.")),
+ },
+ },
+ },
+ },
+ {
+ name: "when user has required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessOpenUser)
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("List").Return([]hotline.Account{
+ {
+ Name: "guest",
+ Login: "guest",
+ Password: "zz",
+ Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranGetClientInfoText, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte{
+ 0x00, 0x04, 0x00, 0x66, 0x00, 0x05, 0x67, 0x75, 0x65, 0x73, 0x74, 0x00, 0x69, 0x00, 0x05, 0x98,
+ 0x8a, 0x9a, 0x8c, 0x8b, 0x00, 0x6e, 0x00, 0x08, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
+ 0x00, 0x6a, 0x00, 0x01, 0x78,
+ }),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleListUsers(tt.args.cc, &tt.args.t)
+
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleDownloadFile(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "when user does not have required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{},
+ },
+ t: hotline.NewTransaction(hotline.TranDownloadFile, [2]byte{0, 1}),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to download files.")),
+ },
+ },
+ },
+ },
+ {
+ name: "with a valid file",
+ args: args{
+ cc: &hotline.ClientConn{
+ ClientFileTransferMgr: hotline.NewClientFileTransferMgr(),
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessDownloadFile)
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ FS: &hotline.OSFileStore{},
+ FileTransferMgr: hotline.NewMemFileTransferMgr(),
+ Config: hotline.Config{
+ FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(),
+ },
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranDownloadFile,
+ [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testfile.txt")),
+ hotline.NewField(hotline.FieldFilePath, []byte{0x0, 0x00}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}),
+ hotline.NewField(hotline.FieldWaitingCount, []byte{0x00, 0x00}),
+ hotline.NewField(hotline.FieldTransferSize, []byte{0x00, 0x00, 0x00, 0xa5}),
+ hotline.NewField(hotline.FieldFileSize, []byte{0x00, 0x00, 0x00, 0x17}),
+ },
+ },
+ },
+ },
+ {
+ name: "when client requests to resume 1k test file at offset 256",
+ args: args{
+ cc: &hotline.ClientConn{
+ ClientFileTransferMgr: hotline.NewClientFileTransferMgr(),
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessDownloadFile)
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ FS: &hotline.OSFileStore{},
+
+ // FS: func() *hotline.MockFileStore {
+ // path, _ := os.Getwd()
+ // testFile, err := os.Open(path + "/test/config/Files/testfile-1k")
+ // if err != nil {
+ // panic(err)
+ // }
+ //
+ // mfi := &hotline.MockFileInfo{}
+ // mfi.On("Mode").Return(fs.FileMode(0))
+ // mfs := &MockFileStore{}
+ // mfs.On("Stat", "/fakeRoot/Files/testfile.txt").Return(mfi, nil)
+ // mfs.On("Open", "/fakeRoot/Files/testfile.txt").Return(testFile, nil)
+ // mfs.On("Stat", "/fakeRoot/Files/.info_testfile.txt").Return(nil, errors.New("no"))
+ // mfs.On("Stat", "/fakeRoot/Files/.rsrc_testfile.txt").Return(nil, errors.New("no"))
+ //
+ // return mfs
+ // }(),
+ FileTransferMgr: hotline.NewMemFileTransferMgr(),
+ Config: hotline.Config{
+ FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(),
+ },
+ //Accounts: map[string]*Account{},
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranDownloadFile,
+ [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testfile-1k")),
+ hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x00}),
+ hotline.NewField(
+ hotline.FieldFileResumeData,
+ func() []byte {
+ frd := hotline.FileResumeData{
+ ForkCount: [2]byte{0, 2},
+ ForkInfoList: []hotline.ForkInfoList{
+ {
+ Fork: [4]byte{0x44, 0x41, 0x54, 0x41}, // "DATA"
+ DataSize: [4]byte{0, 0, 0x01, 0x00}, // request offset 256
+ },
+ {
+ Fork: [4]byte{0x4d, 0x41, 0x43, 0x52}, // "MACR"
+ DataSize: [4]byte{0, 0, 0, 0},
+ },
+ },
+ }
+ b, _ := frd.BinaryMarshal()
+ return b
+ }(),
+ ),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}),
+ hotline.NewField(hotline.FieldWaitingCount, []byte{0x00, 0x00}),
+ hotline.NewField(hotline.FieldTransferSize, []byte{0x00, 0x00, 0x03, 0x8d}),
+ hotline.NewField(hotline.FieldFileSize, []byte{0x00, 0x00, 0x03, 0x00}),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleDownloadFile(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleUpdateUser(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "when action is create user without required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Logger: NewTestLogger(),
+ Server: &hotline.Server{
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Get", "bbb").Return((*hotline.Account)(nil))
+ return &m
+ }(),
+ Logger: NewTestLogger(),
+ },
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{},
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranUpdateUser,
+ [2]byte{0, 0},
+ hotline.NewField(hotline.FieldData, []byte{
+ 0x00, 0x04, // field count
+
+ 0x00, 0x69, // FieldUserLogin = 105
+ 0x00, 0x03,
+ 0x9d, 0x9d, 0x9d,
+
+ 0x00, 0x6a, // FieldUserPassword = 106
+ 0x00, 0x03,
+ 0x9c, 0x9c, 0x9c,
+
+ 0x00, 0x66, // FieldUserName = 102
+ 0x00, 0x03,
+ 0x61, 0x61, 0x61,
+
+ 0x00, 0x6e, // FieldUserAccess = 110
+ 0x00, 0x08,
+ 0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00,
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to create new accounts.")),
+ },
+ },
+ },
+ },
+ {
+ name: "when action is modify user without required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Logger: NewTestLogger(),
+ Server: &hotline.Server{
+ Logger: NewTestLogger(),
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Get", "bbb").Return(&hotline.Account{})
+ return &m
+ }(),
+ },
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranUpdateUser,
+ [2]byte{0, 0},
+ hotline.NewField(hotline.FieldData, []byte{
+ 0x00, 0x04, // field count
+
+ 0x00, 0x69, // FieldUserLogin = 105
+ 0x00, 0x03,
+ 0x9d, 0x9d, 0x9d,
+
+ 0x00, 0x6a, // FieldUserPassword = 106
+ 0x00, 0x03,
+ 0x9c, 0x9c, 0x9c,
+
+ 0x00, 0x66, // FieldUserName = 102
+ 0x00, 0x03,
+ 0x61, 0x61, 0x61,
+
+ 0x00, 0x6e, // FieldUserAccess = 110
+ 0x00, 0x08,
+ 0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00,
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to modify accounts.")),
+ },
+ },
+ },
+ },
+ {
+ name: "when action is delete user without required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Logger: NewTestLogger(),
+ Server: &hotline.Server{},
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{},
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranUpdateUser,
+ [2]byte{0, 0},
+ hotline.NewField(hotline.FieldData, []byte{
+ 0x00, 0x01,
+ 0x00, 0x65,
+ 0x00, 0x03,
+ 0x88, 0x9e, 0x8b,
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete accounts.")),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleUpdateUser(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleDelNewsArt(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "without required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranDelNewsArt,
+ [2]byte{0, 0},
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete news articles.")),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleDelNewsArt(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleDisconnectUser(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "without required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranDelNewsArt,
+ [2]byte{0, 0},
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to disconnect users.")),
+ },
+ },
+ },
+ },
+ {
+ name: "when target user has 'cannot be disconnected' priv",
+ args: args{
+ cc: &hotline.ClientConn{
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("Get", hotline.ClientID{0x0, 0x1}).Return(&hotline.ClientConn{
+ Account: &hotline.Account{
+ Login: "unnamed",
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessCannotBeDiscon)
+ return bits
+ }(),
+ },
+ },
+ )
+ return &m
+ }(),
+ },
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessDisconUser)
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranDelNewsArt,
+ [2]byte{0, 0},
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("unnamed is not allowed to be disconnected.")),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleDisconnectUser(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleSendInstantMsg(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "without required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranDelNewsArt,
+ [2]byte{0, 0},
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to send private messages.")),
+ },
+ },
+ },
+ },
+ {
+ name: "when client 1 sends a message to client 2",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessSendPrivMsg)
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ UserName: []byte("User1"),
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{
+ AutoReply: []byte(nil),
+ Flags: [2]byte{0, 0},
+ },
+ )
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranSendInstantMsg,
+ [2]byte{0, 1},
+ hotline.NewField(hotline.FieldData, []byte("hai")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 2}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ hotline.NewTransaction(
+ hotline.TranServerMsg,
+ [2]byte{0, 2},
+ hotline.NewField(hotline.FieldData, []byte("hai")),
+ hotline.NewField(hotline.FieldUserName, []byte("User1")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldOptions, []byte{0, 1}),
+ ),
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ Fields: []hotline.Field(nil),
+ },
+ },
+ },
+ {
+ name: "when client 2 has autoreply enabled",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessSendPrivMsg)
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ UserName: []byte("User1"),
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{
+ Flags: [2]byte{0, 0},
+ ID: [2]byte{0, 2},
+ UserName: []byte("User2"),
+ AutoReply: []byte("autohai"),
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranSendInstantMsg,
+ [2]byte{0, 1},
+ hotline.NewField(hotline.FieldData, []byte("hai")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 2}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ hotline.NewTransaction(
+ hotline.TranServerMsg,
+ [2]byte{0, 2},
+ hotline.NewField(hotline.FieldData, []byte("hai")),
+ hotline.NewField(hotline.FieldUserName, []byte("User1")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldOptions, []byte{0, 1}),
+ ),
+ hotline.NewTransaction(
+ hotline.TranServerMsg,
+ [2]byte{0, 1},
+ hotline.NewField(hotline.FieldData, []byte("autohai")),
+ hotline.NewField(hotline.FieldUserName, []byte("User2")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 2}),
+ hotline.NewField(hotline.FieldOptions, []byte{0, 1}),
+ ),
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ Fields: []hotline.Field(nil),
+ },
+ },
+ },
+ {
+ name: "when client 2 has refuse private messages enabled",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessSendPrivMsg)
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ UserName: []byte("User1"),
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{
+ Flags: [2]byte{255, 255},
+ ID: [2]byte{0, 2},
+ UserName: []byte("User2"),
+ },
+ )
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranSendInstantMsg,
+ [2]byte{0, 1},
+ hotline.NewField(hotline.FieldData, []byte("hai")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 2}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ hotline.NewTransaction(
+ hotline.TranServerMsg,
+ [2]byte{0, 1},
+ hotline.NewField(hotline.FieldData, []byte("User2 does not accept private messages.")),
+ hotline.NewField(hotline.FieldUserName, []byte("User2")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 2}),
+ hotline.NewField(hotline.FieldOptions, []byte{0, 2}),
+ ),
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ Fields: []hotline.Field(nil),
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleSendInstantMsg(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleDeleteFile(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "when user does not have required permission to delete a folder",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ Config: hotline.Config{
+ FileRoot: func() string {
+ return "/fakeRoot/Files"
+ }(),
+ },
+ FS: func() *hotline.MockFileStore {
+ mfi := &hotline.MockFileInfo{}
+ mfi.On("Mode").Return(fs.FileMode(0))
+ mfi.On("Size").Return(int64(100))
+ mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout))
+ mfi.On("IsDir").Return(false)
+ mfi.On("Name").Return("testfile")
+
+ mfs := &hotline.MockFileStore{}
+ mfs.On("Stat", "/fakeRoot/Files/aaa/testfile").Return(mfi, nil)
+ mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfile").Return(nil, errors.New("err"))
+ mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil, errors.New("err"))
+
+ return mfs
+ }(),
+ //Accounts: map[string]*Account{},
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranDeleteFile, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testfile")),
+ hotline.NewField(hotline.FieldFilePath, []byte{
+ 0x00, 0x01,
+ 0x00, 0x00,
+ 0x03,
+ 0x61, 0x61, 0x61,
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete files.")),
+ },
+ },
+ },
+ },
+ {
+ name: "deletes all associated metadata files",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessDeleteFile)
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ Config: hotline.Config{
+ FileRoot: func() string {
+ return "/fakeRoot/Files"
+ }(),
+ },
+ FS: func() *hotline.MockFileStore {
+ mfi := &hotline.MockFileInfo{}
+ mfi.On("Mode").Return(fs.FileMode(0))
+ mfi.On("Size").Return(int64(100))
+ mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout))
+ mfi.On("IsDir").Return(false)
+ mfi.On("Name").Return("testfile")
+
+ mfs := &hotline.MockFileStore{}
+ mfs.On("Stat", "/fakeRoot/Files/aaa/testfile").Return(mfi, nil)
+ mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfile").Return(nil, errors.New("err"))
+ mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil, errors.New("err"))
+
+ mfs.On("RemoveAll", "/fakeRoot/Files/aaa/testfile").Return(nil)
+ mfs.On("Remove", "/fakeRoot/Files/aaa/testfile.incomplete").Return(nil)
+ mfs.On("Remove", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil)
+ mfs.On("Remove", "/fakeRoot/Files/aaa/.info_testfile").Return(nil)
+
+ return mfs
+ }(),
+ //Accounts: map[string]*Account{},
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranDeleteFile, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testfile")),
+ hotline.NewField(hotline.FieldFilePath, []byte{
+ 0x00, 0x01,
+ 0x00, 0x00,
+ 0x03,
+ 0x61, 0x61, 0x61,
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ Fields: []hotline.Field(nil),
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleDeleteFile(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+
+ tt.args.cc.Server.FS.(*hotline.MockFileStore).AssertExpectations(t)
+ })
+ }
+}
+
+func TestHandleGetFileNameList(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "when FieldFilePath is a drop box, but user does not have AccessViewDropBoxes ",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+
+ Config: hotline.Config{
+ FileRoot: func() string {
+ path, _ := os.Getwd()
+ return filepath.Join(path, "/test/config/Files/getFileNameListTestDir")
+ }(),
+ },
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranGetFileNameList, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFilePath, []byte{
+ 0x00, 0x01,
+ 0x00, 0x00,
+ 0x08,
+ 0x64, 0x72, 0x6f, 0x70, 0x20, 0x62, 0x6f, 0x78, // "drop box"
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to view drop boxes.")),
+ },
+ },
+ },
+ },
+ {
+ name: "with file root",
+ args: args{
+ cc: &hotline.ClientConn{
+ Server: &hotline.Server{
+ Config: hotline.Config{
+ FileRoot: func() string {
+ path, _ := os.Getwd()
+ return filepath.Join(path, "/test/config/Files/getFileNameListTestDir")
+ }(),
+ },
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranGetFileNameList, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFilePath, []byte{
+ 0x00, 0x00,
+ 0x00, 0x00,
+ }),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ Fields: []hotline.Field{
+ hotline.NewField(
+ hotline.FieldFileNameWithInfo,
+ func() []byte {
+ fnwi := hotline.FileNameWithInfo{
+ FileNameWithInfoHeader: hotline.FileNameWithInfoHeader{
+ Type: [4]byte{0x54, 0x45, 0x58, 0x54},
+ Creator: [4]byte{0x54, 0x54, 0x58, 0x54},
+ FileSize: [4]byte{0, 0, 0x04, 0},
+ RSVD: [4]byte{},
+ NameScript: [2]byte{},
+ NameSize: [2]byte{0, 0x0b},
+ },
+ Name: []byte("testfile-1k"),
+ }
+ b, _ := io.ReadAll(&fnwi)
+ return b
+ }(),
+ ),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleGetFileNameList(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleGetClientInfoText(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "when user does not have required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ //Accounts: map[string]*Account{},
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranGetClientInfoText, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to get client info.")),
+ },
+ },
+ },
+ },
+ {
+ name: "with a valid user",
+ args: args{
+ cc: &hotline.ClientConn{
+ UserName: []byte("Testy McTest"),
+ RemoteAddr: "1.2.3.4:12345",
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessGetClientInfo)
+ return bits
+ }(),
+ Name: "test",
+ Login: "test",
+ },
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("Get", hotline.ClientID{0x0, 0x1}).Return(&hotline.ClientConn{
+ UserName: []byte("Testy McTest"),
+ RemoteAddr: "1.2.3.4:12345",
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessGetClientInfo)
+ return bits
+ }(),
+ Name: "test",
+ Login: "test",
+ },
+ },
+ )
+ return &m
+ }(),
+ },
+ ClientFileTransferMgr: hotline.ClientFileTransferMgr{},
+ },
+ t: hotline.NewTransaction(
+ hotline.TranGetClientInfoText, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte(
+ strings.ReplaceAll(`Nickname: Testy McTest
+Name: test
+Account: test
+Address: 1.2.3.4:12345
+
+-------- File Downloads ---------
+
+None.
+
+------- Folder Downloads --------
+
+None.
+
+--------- File Uploads ----------
+
+None.
+
+-------- Folder Uploads ---------
+
+None.
+
+------- Waiting Downloads -------
+
+None.
+
+`, "\n", "\r")),
+ ),
+ hotline.NewField(hotline.FieldUserName, []byte("Testy McTest")),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleGetClientInfoText(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleTranAgreed(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "normal request flow",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessDisconUser)
+ bits.Set(hotline.AccessAnyName)
+ return bits
+ }()},
+ Icon: []byte{0, 1},
+ Flags: [2]byte{0, 1},
+ Version: []byte{0, 1},
+ ID: [2]byte{0, 1},
+ Logger: NewTestLogger(),
+ Server: &hotline.Server{
+ Config: hotline.Config{
+ BannerFile: "Banner.jpg",
+ },
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ //{
+ // ID: [2]byte{0, 2},
+ // UserName: []byte("UserB"),
+ //},
+ },
+ )
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranAgreed, [2]byte{},
+ hotline.NewField(hotline.FieldUserName, []byte("username")),
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldOptions, []byte{0, 0}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0, 0x7a},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldBannerType, []byte("JPEG")),
+ },
+ },
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ Fields: []hotline.Field{},
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleTranAgreed(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleSetClientUserInfo(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "when client does not have AccessAnyName",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ UserName: []byte("Guest"),
+ Flags: [2]byte{0, 1},
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{
+ {
+ ID: [2]byte{0, 1},
+ },
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranSetClientUserInfo, [2]byte{},
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserName, []byte("NOPE")),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0x01, 0x2d},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserName, []byte("Guest"))},
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleSetClientUserInfo(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleDelNewsItem(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "when user does not have permission to delete a news category",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{},
+ },
+ ID: [2]byte{0, 1},
+ Server: &hotline.Server{
+ ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr {
+ m := hotline.MockThreadNewsMgr{}
+ m.On("NewsItem", []string{"test"}).Return(hotline.NewsCategoryListData15{
+ Type: hotline.NewsCategory,
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranDelNewsItem, [2]byte{},
+ hotline.NewField(hotline.FieldNewsPath,
+ []byte{
+ 0, 1,
+ 0, 0,
+ 4,
+ 0x74, 0x65, 0x73, 0x74,
+ },
+ ),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete news categories.")),
+ },
+ },
+ },
+ },
+ {
+ name: "when user does not have permission to delete a news folder",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{},
+ },
+ ID: [2]byte{0, 1},
+ Server: &hotline.Server{
+ ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr {
+ m := hotline.MockThreadNewsMgr{}
+ m.On("NewsItem", []string{"test"}).Return(hotline.NewsCategoryListData15{
+ Type: hotline.NewsBundle,
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranDelNewsItem, [2]byte{},
+ hotline.NewField(hotline.FieldNewsPath,
+ []byte{
+ 0, 1,
+ 0, 0,
+ 4,
+ 0x74, 0x65, 0x73, 0x74,
+ },
+ ),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete news folders.")),
+ },
+ },
+ },
+ },
+ {
+ name: "when user deletes a news folder",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessNewsDeleteFldr)
+ return bits
+ }(),
+ },
+ ID: [2]byte{0, 1},
+ Server: &hotline.Server{
+ ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr {
+ m := hotline.MockThreadNewsMgr{}
+ m.On("NewsItem", []string{"test"}).Return(hotline.NewsCategoryListData15{Type: hotline.NewsBundle})
+ m.On("DeleteNewsItem", []string{"test"}).Return(nil)
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranDelNewsItem, [2]byte{},
+ hotline.NewField(hotline.FieldNewsPath,
+ []byte{
+ 0, 1,
+ 0, 0,
+ 4,
+ 0x74, 0x65, 0x73, 0x74,
+ },
+ ),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ Fields: []hotline.Field{},
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleDelNewsItem(tt.args.cc, &tt.args.t)
+
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleTranOldPostNews(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "when user does not have required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: hotline.AccessBitmap{},
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranOldPostNews, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldData, []byte("hai")),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to post news.")),
+ },
+ },
+ },
+ },
+ {
+ name: "when user posts news update",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessNewsPostArt)
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ Config: hotline.Config{
+ NewsDateFormat: "",
+ },
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("List").Return([]*hotline.ClientConn{})
+ return &m
+ }(),
+ MessageBoard: func() *mockReadWriteSeeker {
+ m := mockReadWriteSeeker{}
+ m.On("Seek", int64(0), 0).Return(int64(0), nil)
+ m.On("Read", mock.AnythingOfType("[]uint8")).Run(func(args mock.Arguments) {
+ arg := args.Get(0).([]uint8)
+ copy(arg, "TEST")
+ }).Return(4, io.EOF)
+ m.On("Write", mock.AnythingOfType("[]uint8")).Return(3, nil)
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranOldPostNews, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldData, []byte("hai")),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleTranOldPostNews(tt.args.cc, &tt.args.t)
+
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleInviteNewChat(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "when user does not have required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(hotline.TranInviteNewChat, [2]byte{0, 1}),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to request private chat.")),
+ },
+ },
+ },
+ },
+ {
+ name: "when userA invites userB to new private chat",
+ args: args{
+ cc: &hotline.ClientConn{
+ ID: [2]byte{0, 1},
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessOpenChat)
+ return bits
+ }(),
+ },
+ UserName: []byte("UserA"),
+ Icon: []byte{0, 1},
+ Flags: [2]byte{0, 0},
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{
+ ID: [2]byte{0, 2},
+ UserName: []byte("UserB"),
+ })
+ return &m
+ }(),
+ ChatMgr: func() *hotline.MockChatManager {
+ m := hotline.MockChatManager{}
+ m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(hotline.ChatID{0x52, 0xfd, 0xfc, 0x07})
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranInviteNewChat, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldUserID, []byte{0, 2}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 2},
+ Type: [2]byte{0, 0x71},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}),
+ hotline.NewField(hotline.FieldUserName, []byte("UserA")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ },
+ },
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}),
+ hotline.NewField(hotline.FieldUserName, []byte("UserA")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 0}),
+ },
+ },
+ },
+ },
+ {
+ name: "when userA invites userB to new private chat, but UserB has refuse private chat enabled",
+ args: args{
+ cc: &hotline.ClientConn{
+ ID: [2]byte{0, 1},
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessOpenChat)
+ return bits
+ }(),
+ },
+ UserName: []byte("UserA"),
+ Icon: []byte{0, 1},
+ Flags: [2]byte{0, 0},
+ Server: &hotline.Server{
+ ClientMgr: func() *hotline.MockClientMgr {
+ m := hotline.MockClientMgr{}
+ m.On("Get", hotline.ClientID{0, 2}).Return(&hotline.ClientConn{
+ ID: [2]byte{0, 2},
+ Icon: []byte{0, 1},
+ UserName: []byte("UserB"),
+ Flags: [2]byte{255, 255},
+ })
+ return &m
+ }(),
+ ChatMgr: func() *hotline.MockChatManager {
+ m := hotline.MockChatManager{}
+ m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(hotline.ChatID{0x52, 0xfd, 0xfc, 0x07})
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranInviteNewChat, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldUserID, []byte{0, 2}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ Type: [2]byte{0, 0x68},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte("UserB does not accept private chats.")),
+ hotline.NewField(hotline.FieldUserName, []byte("UserB")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 2}),
+ hotline.NewField(hotline.FieldOptions, []byte{0, 2}),
+ },
+ },
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}),
+ hotline.NewField(hotline.FieldUserName, []byte("UserA")),
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldUserFlags, []byte{0, 0}),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+
+ gotRes := HandleInviteNewChat(tt.args.cc, &tt.args.t)
+
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleGetNewsArtData(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "when user does not have required permission",
+ args: args{
+ cc: &hotline.ClientConn{Account: &hotline.Account{}},
+ t: hotline.NewTransaction(
+ hotline.TranGetNewsArtData, [2]byte{0, 1},
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to read news.")),
+ },
+ },
+ },
+ },
+ {
+ name: "when user has required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessNewsReadArt)
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr {
+ m := hotline.MockThreadNewsMgr{}
+ m.On("GetArticle", []string{"Example Category"}, uint32(1)).Return(&hotline.NewsArtData{
+ Title: "title",
+ Poster: "poster",
+ Date: [8]byte{},
+ PrevArt: [4]byte{0, 0, 0, 1},
+ NextArt: [4]byte{0, 0, 0, 2},
+ ParentArt: [4]byte{0, 0, 0, 3},
+ FirstChildArt: [4]byte{0, 0, 0, 4},
+ DataFlav: []byte("text/plain"),
+ Data: "article data",
+ })
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranGetNewsArtData, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldNewsPath, []byte{
+ // Example Category
+ 0x00, 0x01, 0x00, 0x00, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79,
+ }),
+ hotline.NewField(hotline.FieldNewsArtID, []byte{0, 1}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 1,
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldNewsArtTitle, []byte("title")),
+ hotline.NewField(hotline.FieldNewsArtPoster, []byte("poster")),
+ hotline.NewField(hotline.FieldNewsArtDate, []byte{0, 0, 0, 0, 0, 0, 0, 0}),
+ hotline.NewField(hotline.FieldNewsArtPrevArt, []byte{0, 0, 0, 1}),
+ hotline.NewField(hotline.FieldNewsArtNextArt, []byte{0, 0, 0, 2}),
+ hotline.NewField(hotline.FieldNewsArtParentArt, []byte{0, 0, 0, 3}),
+ hotline.NewField(hotline.FieldNewsArt1stChildArt, []byte{0, 0, 0, 4}),
+ hotline.NewField(hotline.FieldNewsArtDataFlav, []byte("text/plain")),
+ hotline.NewField(hotline.FieldNewsArtData, []byte("article data")),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleGetNewsArtData(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleGetNewsArtNameList(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "when user does not have required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ //Accounts: map[string]*Account{},
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranGetNewsArtNameList, [2]byte{0, 1},
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ Flags: 0x00,
+ IsReply: 0x01,
+ Type: [2]byte{0, 0},
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to read news.")),
+ },
+ },
+ },
+ },
+ //{
+ // name: "when user has required access",
+ // args: args{
+ // cc: &hotline.ClientConn{
+ // Account: &hotline.Account{
+ // Access: func() hotline.AccessBitmap {
+ // var bits hotline.AccessBitmap
+ // bits.Set(hotline.AccessNewsReadArt)
+ // return bits
+ // }(),
+ // },
+ // Server: &hotline.Server{
+ // ThreadedNewsMgr: func() *mockThreadNewsMgr {
+ // m := mockThreadNewsMgr{}
+ // m.On("ListArticles", []string{"Example Category"}).Return(NewsArtListData{
+ // Name: []byte("testTitle"),
+ // NewsArtList: []byte{},
+ // })
+ // return &m
+ // }(),
+ // },
+ // },
+ // t: NewTransaction(
+ // TranGetNewsArtNameList,
+ // [2]byte{0, 1},
+ // // 00000000 00 01 00 00 10 45 78 61 6d 70 6c 65 20 43 61 74 |.....Example Cat|
+ // // 00000010 65 67 6f 72 79 |egory|
+ // NewField(hotline.FieldNewsPath, []byte{
+ // 0x00, 0x01, 0x00, 0x00, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79,
+ // }),
+ // ),
+ // },
+ // wantRes: []hotline.Transaction{
+ // {
+ // IsReply: 0x01,
+ // Fields: []hotline.Field{
+ // NewField(hotline.FieldNewsArtListData, []byte{
+ // 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00,
+ // 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
+ // 0x09, 0x74, 0x65, 0x73, 0x74, 0x54, 0x69, 0x74, 0x6c, 0x65, 0x0a, 0x74, 0x65, 0x73, 0x74, 0x50,
+ // 0x6f, 0x73, 0x74, 0x65, 0x72, 0x0a, 0x74, 0x65, 0x78, 0x74, 0x2f, 0x70, 0x6c, 0x61, 0x69, 0x6e,
+ // 0x00, 0x08,
+ // },
+ // ),
+ // },
+ // },
+ // },
+ //},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleGetNewsArtNameList(tt.args.cc, &tt.args.t)
+
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleNewNewsFldr(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "when user does not have required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ Server: &hotline.Server{
+ //Accounts: map[string]*Account{},
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranGetNewsArtNameList, [2]byte{0, 1},
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ Flags: 0x00,
+ IsReply: 0x01,
+ Type: [2]byte{0, 0},
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to create news folders.")),
+ },
+ },
+ },
+ },
+ {
+ name: "with a valid request",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessNewsCreateFldr)
+ return bits
+ }(),
+ },
+ Logger: NewTestLogger(),
+ ID: [2]byte{0, 1},
+ Server: &hotline.Server{
+ ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr {
+ m := hotline.MockThreadNewsMgr{}
+ m.On("CreateGrouping", []string{"test"}, "testFolder", hotline.NewsBundle).Return(nil)
+ return &m
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranGetNewsArtNameList, [2]byte{0, 1},
+ hotline.NewField(hotline.FieldFileName, []byte("testFolder")),
+ hotline.NewField(hotline.FieldNewsPath,
+ []byte{
+ 0, 1,
+ 0, 0,
+ 4,
+ 0x74, 0x65, 0x73, 0x74,
+ },
+ ),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ Fields: []hotline.Field{},
+ },
+ },
+ },
+ //{
+ // Name: "when there is an error writing the threaded news file",
+ // args: args{
+ // cc: &hotline.ClientConn{
+ // Account: &hotline.Account{
+ // Access: func() hotline.AccessBitmap {
+ // var bits hotline.AccessBitmap
+ // bits.Set(hotline.AccessNewsCreateFldr)
+ // return bits
+ // }(),
+ // },
+ // logger: NewTestLogger(),
+ // Type: [2]byte{0, 1},
+ // Server: &hotline.Server{
+ // ConfigDir: "/fakeConfigRoot",
+ // FS: func() *hotline.MockFileStore {
+ // mfs := &MockFileStore{}
+ // mfs.On("WriteFile", "/fakeConfigRoot/ThreadedNews.yaml", mock.Anything, mock.Anything).Return(os.ErrNotExist)
+ // return mfs
+ // }(),
+ // ThreadedNews: &ThreadedNews{Categories: map[string]NewsCategoryListData15{
+ // "test": {
+ // Type: []byte{0, 2},
+ // Count: nil,
+ // NameSize: 0,
+ // Name: "test",
+ // SubCats: make(map[string]NewsCategoryListData15),
+ // },
+ // }},
+ // },
+ // },
+ // t: NewTransaction(
+ // TranGetNewsArtNameList, [2]byte{0, 1},
+ // NewField(hotline.FieldFileName, []byte("testFolder")),
+ // NewField(hotline.FieldNewsPath,
+ // []byte{
+ // 0, 1,
+ // 0, 0,
+ // 4,
+ // 0x74, 0x65, 0x73, 0x74,
+ // },
+ // ),
+ // ),
+ // },
+ // wantRes: []hotline.Transaction{
+ // {
+ // ClientID: [2]byte{0, 1},
+ // Flags: 0x00,
+ // IsReply: 0x01,
+ // Type: [2]byte{0, 0},
+ // ErrorCode: [4]byte{0, 0, 0, 1},
+ // Fields: []hotline.Field{
+ // NewField(hotline.FieldError, []byte("Error creating news folder.")),
+ // },
+ // },
+ // },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleNewNewsFldr(tt.args.cc, &tt.args.t)
+
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleDownloadBanner(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ // TODO: Add test cases.
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleDownloadBanner(tt.args.cc, &tt.args.t)
+
+ assert.Equalf(t, tt.wantRes, gotRes, "HandleDownloadBanner(%v, %v)", tt.args.cc, &tt.args.t)
+ })
+ }
+}
+
+func TestHandlePostNewsArt(t *testing.T) {
+ type args struct {
+ cc *hotline.ClientConn
+ t hotline.Transaction
+ }
+ tests := []struct {
+ name string
+ args args
+ wantRes []hotline.Transaction
+ }{
+ {
+ name: "without required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranPostNewsArt,
+ [2]byte{0, 0},
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("You are not allowed to post news articles.")),
+ },
+ },
+ },
+ },
+ {
+ name: "with required permission",
+ args: args{
+ cc: &hotline.ClientConn{
+ Server: &hotline.Server{
+ ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr {
+ m := hotline.MockThreadNewsMgr{}
+ m.On("PostArticle", []string{"www"}, uint32(0), mock.AnythingOfType("hotline.NewsArtData")).Return(nil)
+ return &m
+ }(),
+ },
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessNewsPostArt)
+ return bits
+ }(),
+ },
+ },
+ t: hotline.NewTransaction(
+ hotline.TranPostNewsArt,
+ [2]byte{0, 0},
+ hotline.NewField(hotline.FieldNewsPath, []byte{0x00, 0x01, 0x00, 0x00, 0x03, 0x77, 0x77, 0x77}),
+ hotline.NewField(hotline.FieldNewsArtID, []byte{0x00, 0x00, 0x00, 0x00}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 0},
+ Fields: []hotline.Field{},
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ TranAssertEqual(t, tt.wantRes, HandlePostNewsArt(tt.args.cc, &tt.args.t))
+ })
+ }
+}