"flag"
"fmt"
"github.com/jhalter/mobius/hotline"
+ "github.com/jhalter/mobius/internal/mobius"
"gopkg.in/natefinch/lumberjack.v2"
"io"
"log"
"log/slog"
"net/http"
"os"
+ "os/signal"
"path"
"runtime"
+ "syscall"
)
//go:embed mobius/config
)
func main() {
- ctx, _ := context.WithCancel(context.Background())
-
- // TODO: implement graceful shutdown by closing context
- //c := make(chan os.Signal, 1)
- //signal.Notify(c, os.Interrupt)
- //defer func() {
- // signal.Stop(c)
- // cancel()
- //}()
- //go func() {
- // select {
- // case <-c:
- // cancel()
- // case <-ctx.Done():
- // }
- //}()
+ ctx, cancel := context.WithCancel(context.Background())
+
+ sigChan := make(chan os.Signal, 1)
+ 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.")
statsPort := flag.String("stats-port", "", "Enable stats HTTP endpoint on address and port")
configDir := flag.String("config", defaultConfigPath(), "Path to config root")
- printVersion := flag.Bool("version", false, "print version and exit")
+ printVersion := flag.Bool("version", false, "Print version and exit")
logLevel := flag.String("log-level", "info", "Log level")
logFile := flag.String("log-file", "", "Path to log file")
os.Exit(1)
}
- srv, err := hotline.NewServer(*configDir, *netInterface, *basePort, slogger, &hotline.OSFileStore{})
+ 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{})
if err != nil {
slogger.Error(fmt.Sprintf("Error starting server: %s", err))
os.Exit(1)
}
+ srv.MessageBoard, err = mobius.NewFlatNews(path.Join(*configDir, "MessageBoard.txt"))
+ if err != nil {
+ slogger.Error(fmt.Sprintf("Error loading message board: %v", err))
+ os.Exit(1)
+ }
+
+ srv.BanList, err = mobius.NewBanFile(path.Join(*configDir, "Banlist.yaml"))
+ if err != nil {
+ slogger.Error(fmt.Sprintf("Error loading ban list: %v", err))
+ os.Exit(1)
+ }
+
+ srv.ThreadedNewsMgr, err = mobius.NewThreadedNewsYAML(path.Join(*configDir, "ThreadedNews.yaml"))
+ if err != nil {
+ slogger.Error(fmt.Sprintf("Error loading news: %v", err))
+ os.Exit(1)
+ }
+
sh := statHandler{hlServer: srv}
if *statsPort != "" {
http.HandleFunc("/", sh.RenderStats)
}(srv)
}
+ go func() {
+ for {
+ sig := <-sigChan
+ switch sig {
+ 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)
+ }
+ default:
+ signal.Stop(sigChan)
+ cancel()
+ os.Exit(0)
+ }
+
+ }
+ }()
+
slogger.Info("Hotline server started",
"version", version,
"API port", fmt.Sprintf("%s:%v", *netInterface, *basePort),
return cfgPath
}
-// copyFile copies a file from src to dst. If dst does not exist, it is created.
-func copyFile(src, dst string) error {
- sourceFile, err := os.Open(src)
- if err != nil {
- return err
- }
- defer sourceFile.Close()
-
- destinationFile, err := os.Create(dst)
- if err != nil {
- return err
- }
- defer destinationFile.Close()
-
- _, err = io.Copy(destinationFile, sourceFile)
- return err
-}
-
// copyDir recursively copies a directory tree, attempting to preserve permissions.
func copyDir(src, dst string) error {
entries, err := cfgTemplate.ReadDir(src)
package hotline
const (
- accessDeleteFile = 0 // File System Maintenance: Can Delete Files
- accessUploadFile = 1 // File System Maintenance: Can Upload Files
- accessDownloadFile = 2 // File System Maintenance: Can Download Files
- accessRenameFile = 3 // File System Maintenance: Can Rename Files
- accessMoveFile = 4 // File System Maintenance: Can Move Files
- accessCreateFolder = 5 // File System Maintenance: Can Create Folders
- accessDeleteFolder = 6 // File System Maintenance: Can Delete Folders
- accessRenameFolder = 7 // File System Maintenance: Can Rename Folders
- accessMoveFolder = 8 // File System Maintenance: Can Move Folders
- accessReadChat = 9 // Chat: Can Read Chat
- accessSendChat = 10 // Chat: Can Send Chat
- accessOpenChat = 11 // Chat: Can Initial Private Chat
- // accessCloseChat = 12 // Present in the Hotline 1.9 protocol documentation, but seemingly unused
- // accessShowInList = 13 // Present in the Hotline 1.9 protocol documentation, but seemingly unused
- accessCreateUser = 14 // User Maintenance: Can Create Accounts
- accessDeleteUser = 15 // User Maintenance: Can Delete Accounts
- accessOpenUser = 16 // User Maintenance: Can Read Accounts
- accessModifyUser = 17 // User Maintenance: Can Modify Accounts
- // accessChangeOwnPass = 18 // Present in the Hotline 1.9 protocol documentation, but seemingly unused
- accessNewsReadArt = 20 // News: Can Read Articles
- accessNewsPostArt = 21 // News: Can Post Articles
- accessDisconUser = 22 // User Maintenance: Can Disconnect Users (Note: Turns username red in user list)
- accessCannotBeDiscon = 23 // User Maintenance: Cannot be Disconnected
- accessGetClientInfo = 24 // User Maintenance: Can Get User Info
- accessUploadAnywhere = 25 // File System Maintenance: Can Upload Anywhere
- accessAnyName = 26 // Miscellaneous: Can User Any Name
- accessNoAgreement = 27 // Miscellaneous: Don't Show Agreement
- accessSetFileComment = 28 // File System Maintenance: Can Comment Files
- accessSetFolderComment = 29 // File System Maintenance: Can Comment Folders
- accessViewDropBoxes = 30 // File System Maintenance: Can View Drop Boxes
- accessMakeAlias = 31 // File System Maintenance: Can Make Aliases
- accessBroadcast = 32 // Messaging: Can Broadcast
- accessNewsDeleteArt = 33 // News: Can Delete Articles
- accessNewsCreateCat = 34 // News: Can Create Categories
- accessNewsDeleteCat = 35 // News: Can Delete Categories
- accessNewsCreateFldr = 36 // News: Can Create News Bundles
- accessNewsDeleteFldr = 37 // News: Can Delete News Bundles
- accessSendPrivMsg = 40 // Messaging: Can Send Messages (Note: 1.9 protocol doc incorrectly says this is bit 19)
+ AccessDeleteFile = 0 // File System Maintenance: Can Delete Files
+ AccessUploadFile = 1 // File System Maintenance: Can Upload Files
+ AccessDownloadFile = 2 // File System Maintenance: Can Download Files
+ AccessRenameFile = 3 // File System Maintenance: Can Rename Files
+ AccessMoveFile = 4 // File System Maintenance: Can Move Files
+ AccessCreateFolder = 5 // File System Maintenance: Can Create Folders
+ AccessDeleteFolder = 6 // File System Maintenance: Can Delete Folders
+ AccessRenameFolder = 7 // File System Maintenance: Can Rename Folders
+ AccessMoveFolder = 8 // File System Maintenance: Can Move Folders
+ AccessReadChat = 9 // Chat: Can Read Chat
+ AccessSendChat = 10 // Chat: Can Send Chat
+ AccessOpenChat = 11 // Chat: Can Initial Private Chat
+ AccessCloseChat = 12 // Present in the Hotline 1.9 protocol documentation, but seemingly unused
+ AccessShowInList = 13 // Present in the Hotline 1.9 protocol documentation, but seemingly unused
+ AccessCreateUser = 14 // User Maintenance: Can Create Accounts
+ AccessDeleteUser = 15 // User Maintenance: Can Delete Accounts
+ AccessOpenUser = 16 // User Maintenance: Can Read Accounts
+ AccessModifyUser = 17 // User Maintenance: Can Modify Accounts
+ AccessChangeOwnPass = 18 // Present in the Hotline 1.9 protocol documentation, but seemingly unused
+ AccessNewsReadArt = 20 // News: Can Read Articles
+ AccessNewsPostArt = 21 // News: Can Post Articles
+ AccessDisconUser = 22 // User Maintenance: Can Disconnect Users (Note: Turns username red in user list)
+ AccessCannotBeDiscon = 23 // User Maintenance: Cannot be Disconnected
+ AccessGetClientInfo = 24 // User Maintenance: Can Get User Info
+ AccessUploadAnywhere = 25 // File System Maintenance: Can Upload Anywhere
+ AccessAnyName = 26 // Miscellaneous: Can User Any Name
+ AccessNoAgreement = 27 // Miscellaneous: Don't Show Agreement
+ AccessSetFileComment = 28 // File System Maintenance: Can Comment Files
+ AccessSetFolderComment = 29 // File System Maintenance: Can Comment Folders
+ AccessViewDropBoxes = 30 // File System Maintenance: Can View Drop Boxes
+ AccessMakeAlias = 31 // File System Maintenance: Can Make Aliases
+ AccessBroadcast = 32 // Messaging: Can Broadcast
+ AccessNewsDeleteArt = 33 // News: Can Delete Articles
+ AccessNewsCreateCat = 34 // News: Can Create Categories
+ AccessNewsDeleteCat = 35 // News: Can Delete Categories
+ AccessNewsCreateFldr = 36 // News: Can Create News Bundles
+ AccessNewsDeleteFldr = 37 // News: Can Delete News Bundles
+ AccessSendPrivMsg = 40 // Messaging: Can Send Messages (Note: 1.9 protocol doc incorrectly says this is bit 19)
)
type accessBitmap [8]byte
--- /dev/null
+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
+ Get(login string) *Account
+ 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
+
+type BanMgr interface {
+ Add(ip string, until *time.Time) error
+ IsBanned(ip string) (bool, *time.Time)
+}
--- /dev/null
+package hotline
+
+import (
+ "crypto/rand"
+ "github.com/stretchr/testify/mock"
+ "slices"
+ "sync"
+)
+
+type PrivateChat struct {
+ Subject string
+ ClientConn map[[2]byte]*ClientConn
+}
+
+type ChatID [4]byte
+
+type ChatManager interface {
+ New(cc *ClientConn) ChatID
+ GetSubject(id ChatID) string
+ Join(id ChatID, cc *ClientConn)
+ Leave(id ChatID, clientID [2]byte)
+ SetSubject(id ChatID, subject string)
+ 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
+
+ mu sync.Mutex
+}
+
+func NewMemChatManager() *MemChatManager {
+ return &MemChatManager{
+ chats: make(map[ChatID]*PrivateChat),
+ }
+}
+
+func (cm *MemChatManager) New(cc *ClientConn) ChatID {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ var randID [4]byte
+ _, _ = rand.Read(randID[:])
+
+ cm.chats[randID] = &PrivateChat{ClientConn: make(map[[2]byte]*ClientConn)}
+
+ cm.chats[randID].ClientConn[cc.ID] = cc
+
+ return randID
+}
+
+func (cm *MemChatManager) Join(id ChatID, cc *ClientConn) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ chat := cm.chats[id]
+ chat.ClientConn[cc.ID] = cc
+}
+
+func (cm *MemChatManager) Leave(id ChatID, clientID [2]byte) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ privChat, ok := cm.chats[id]
+ if !ok {
+ return
+ }
+
+ delete(privChat.ClientConn, clientID)
+}
+
+func (cm *MemChatManager) GetSubject(id ChatID) string {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ return cm.chats[id].Subject
+}
+
+func (cm *MemChatManager) Members(id ChatID) []*ClientConn {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ chat := cm.chats[id]
+
+ var members []*ClientConn
+ for _, cc := range chat.ClientConn {
+ members = append(members, cc)
+ }
+
+ slices.SortFunc(members, clientConnSortFunc)
+
+ return members
+}
+
+func (cm *MemChatManager) SetSubject(id ChatID, subject string) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ chat := cm.chats[id]
+
+ chat.Subject = subject
+}
--- /dev/null
+package hotline
+
+import (
+ "github.com/stretchr/testify/assert"
+ "testing"
+)
+
+func TestMemChatManager(t *testing.T) {
+ cc1 := &ClientConn{ID: [2]byte{1}}
+ cc2 := &ClientConn{ID: [2]byte{2}}
+
+ cm := NewMemChatManager()
+
+ // Create a new chat with cc1 as initial member.
+ randChatID := cm.New(cc1)
+ assert.Equalf(t, []*ClientConn{cc1}, cm.Members(randChatID), "Initial ChatMembers")
+
+ // Second client joins.
+ cm.Join(randChatID, cc2)
+ assert.Equalf(t, []*ClientConn{cc1, cc2}, cm.Members(randChatID), "ChatMembers")
+
+ // Initial subject is blank.
+ assert.Equalf(t, "", cm.GetSubject(randChatID), "ChatMembers")
+
+ // Update subject.
+ cm.SetSubject(randChatID, "test")
+ assert.Equalf(t, "test", cm.GetSubject(randChatID), "ChatMembers")
+
+ // Second client leaves.
+ cm.Leave(randChatID, cc2.ID)
+ assert.Equalf(t, []*ClientConn{cc1}, cm.Members(randChatID), "ChatMembers")
+
+ //
+ //type fields struct {
+ // chats map[ChatID]*PrivateChat
+ //}
+ //type args struct {
+ // cc *ClientConn
+ //}
+ //tests := []struct {
+ // name string
+ // fields fields
+ // args args
+ // want ChatID
+ //}{
+ // {
+ // name: "creates new chat",
+ // fields: fields{
+ // chats: make(map[ChatID]*PrivateChat),
+ // },
+ // args: args{
+ // cc: &ClientConn{ID: [2]byte{1}},
+ // },
+ // //want: ,
+ // },
+ //}
+ //for _, tt := range tests {
+ // t.Run(tt.name, func(t *testing.T) {
+ // cm := &MemChatManager{
+ // chats: tt.fields.chats,
+ // mu: sync.Mutex{},
+ // }
+ //
+ // assert.Equalf(t, tt.want, cm.New(tt.args.cc), "New(%v)", tt.args.cc)
+ // })
+ //}
+}
}
func (c *Client) Handshake() error {
- // Protocol ID 4 ‘TRTP’ 0x54 52 54 50
- // Sub-protocol ID 4 User defined
+ // Protocol Type 4 ‘TRTP’ 0x54 52 54 50
+ // Sub-protocol Type 4 User defined
// Version 2 1 Currently 1
// Sub-version 2 User defined
if _, err := c.Connection.Write(ClientHandshake); err != nil {
"golang.org/x/crypto/bcrypt"
"io"
"log/slog"
- "slices"
"strings"
"sync"
)
+var clientConnSortFunc = func(a, b *ClientConn) int {
+ return cmp.Compare(
+ binary.BigEndian.Uint16(a.ID[:]),
+ binary.BigEndian.Uint16(b.ID[:]),
+ )
+}
+
// ClientConn represents a client connected to a Server
type ClientConn struct {
Connection io.ReadWriteCloser
RemoteAddr string
- ID [2]byte
- Icon []byte
- flagsMU sync.Mutex
- Flags UserFlags
- UserName []byte
- Account *Account
- IdleTime int
- Server *Server
- Version []byte
- Idle bool
- AutoReply []byte
-
- transfersMU sync.Mutex
- transfers map[int]map[[4]byte]*FileTransfer
+ ID ClientID
+ Icon []byte // TODO: make fixed size of 2
+ Version []byte // TODO: make fixed size of 2
+
+ flagsMU sync.Mutex // TODO: move into UserFlags struct
+ Flags UserFlags
+
+ UserName []byte
+ Account *Account
+ IdleTime int
+ Server *Server // TODO: consider adding methods to interact with server
+ AutoReply []byte
+
+ ClientFileTransferMgr ClientFileTransferMgr
logger *slog.Logger
- sync.Mutex
+ mu sync.RWMutex
+}
+
+type ClientFileTransferMgr struct {
+ transfers map[FileTransferType]map[FileTransferID]*FileTransfer
+
+ mu sync.RWMutex
+}
+
+func NewClientFileTransferMgr() ClientFileTransferMgr {
+ return ClientFileTransferMgr{
+ transfers: map[FileTransferType]map[FileTransferID]*FileTransfer{
+ FileDownload: {},
+ FileUpload: {},
+ FolderDownload: {},
+ FolderUpload: {},
+ BannerDownload: {},
+ },
+ }
+}
+
+func (cftm *ClientFileTransferMgr) Add(ftType FileTransferType, ft *FileTransfer) {
+ cftm.mu.Lock()
+ defer cftm.mu.Unlock()
+
+ cftm.transfers[ftType][ft.refNum] = ft
+}
+
+func (cftm *ClientFileTransferMgr) Get(ftType FileTransferType) []FileTransfer {
+ cftm.mu.Lock()
+ defer cftm.mu.Unlock()
+
+ fts := cftm.transfers[ftType]
+
+ var transfers []FileTransfer
+ for _, ft := range fts {
+ transfers = append(transfers, *ft)
+ }
+
+ return transfers
}
-func (cc *ClientConn) sendAll(t [2]byte, fields ...Field) {
- for _, c := range cc.Server.Clients {
+func (cftm *ClientFileTransferMgr) Delete(ftType FileTransferType, id FileTransferID) {
+ cftm.mu.Lock()
+ defer cftm.mu.Unlock()
+
+ delete(cftm.transfers[ftType], id)
+}
+
+func (cc *ClientConn) SendAll(t [2]byte, fields ...Field) {
+ for _, c := range cc.Server.ClientMgr.List() {
cc.Server.outbox <- NewTransaction(t, c.ID, fields...)
}
}
func (cc *ClientConn) handleTransaction(transaction Transaction) {
if handler, ok := TransactionHandlers[transaction.Type]; ok {
- cc.logger.Debug("Received Transaction", "RequestType", transaction.Type)
+ if transaction.Type != TranKeepAlive {
+ cc.logger.Info(tranTypeNames[transaction.Type])
+ }
for _, t := range handler(cc, &transaction) {
cc.Server.outbox <- t
}
}
- cc.Server.mux.Lock()
- defer cc.Server.mux.Unlock()
-
if transaction.Type != TranKeepAlive {
+ cc.mu.Lock()
+ defer cc.mu.Unlock()
+
// reset the user idle timer
cc.IdleTime = 0
// if user was previously idle, mark as not idle and notify other connected clients that
// the user is no longer away
- if cc.Idle {
+ if cc.Flags.IsSet(UserFlagAway) {
cc.Flags.Set(UserFlagAway, 0)
- cc.Idle = false
- cc.sendAll(
+ cc.SendAll(
TranNotifyChangeUser,
NewField(FieldUserID, cc.ID[:]),
NewField(FieldUserFlags, cc.Flags[:]),
}
func (cc *ClientConn) Authenticate(login string, password []byte) bool {
- if account, ok := cc.Server.Accounts[login]; ok {
+ if account := cc.Server.AccountManager.Get(login); account != nil {
return bcrypt.CompareHashAndPassword([]byte(account.Password), password) == nil
}
// Authorize checks if the user account has the specified permission
func (cc *ClientConn) Authorize(access int) bool {
- cc.Lock()
- defer cc.Unlock()
if cc.Account == nil {
return false
}
// Disconnect notifies other clients that a client has disconnected
func (cc *ClientConn) Disconnect() {
- cc.Server.mux.Lock()
- delete(cc.Server.Clients, cc.ID)
- cc.Server.mux.Unlock()
+ cc.Server.ClientMgr.Delete(cc.ID)
- for _, t := range cc.notifyOthers(NewTransaction(TranNotifyDeleteUser, [2]byte{}, NewField(FieldUserID, cc.ID[:]))) {
+ for _, t := range cc.NotifyOthers(NewTransaction(TranNotifyDeleteUser, [2]byte{}, NewField(FieldUserID, cc.ID[:]))) {
cc.Server.outbox <- t
}
}
}
-// notifyOthers sends transaction t to other clients connected to the server
-func (cc *ClientConn) notifyOthers(t Transaction) (trans []Transaction) {
- cc.Server.mux.Lock()
- defer cc.Server.mux.Unlock()
- for _, c := range cc.Server.Clients {
+// NotifyOthers sends transaction t to other clients connected to the server
+func (cc *ClientConn) NotifyOthers(t Transaction) (trans []Transaction) {
+ for _, c := range cc.Server.ClientMgr.List() {
if c.ID != cc.ID {
t.clientID = c.ID
trans = append(trans, t)
}
}
-var clientSortFunc = func(a, b *ClientConn) int {
- return cmp.Compare(
- binary.BigEndian.Uint16(a.ID[:]),
- binary.BigEndian.Uint16(b.ID[:]),
- )
-}
-
-// sortedClients is a utility function that takes a map of *ClientConn and returns a sorted slice of the values.
-// The purpose of this is to ensure that the ordering of client connections is deterministic so that test assertions work.
-func sortedClients(unsortedClients map[[2]byte]*ClientConn) (clients []*ClientConn) {
- for _, c := range unsortedClients {
- clients = append(clients, c)
- }
-
- slices.SortFunc(clients, clientSortFunc)
-
- return clients
-}
-
const userInfoTemplate = `Nickname: %s
Name: %s
Account: %s
%s
`
-func formatDownloadList(fts map[[4]byte]*FileTransfer) (s string) {
+func formatDownloadList(fts []FileTransfer) (s string) {
if len(fts) == 0 {
return "None.\n"
}
}
func (cc *ClientConn) String() string {
- cc.transfersMU.Lock()
- defer cc.transfersMU.Unlock()
template := fmt.Sprintf(
userInfoTemplate,
cc.UserName,
cc.Account.Name,
cc.Account.Login,
cc.RemoteAddr,
- formatDownloadList(cc.transfers[FileDownload]),
- formatDownloadList(cc.transfers[FolderDownload]),
- formatDownloadList(cc.transfers[FileUpload]),
- formatDownloadList(cc.transfers[FolderUpload]),
+ formatDownloadList(cc.ClientFileTransferMgr.Get(FileDownload)),
+ formatDownloadList(cc.ClientFileTransferMgr.Get(FolderDownload)),
+ formatDownloadList(cc.ClientFileTransferMgr.Get(FileUpload)),
+ formatDownloadList(cc.ClientFileTransferMgr.Get(FolderUpload)),
"None.\n",
)
--- /dev/null
+package hotline
+
+import (
+ "cmp"
+ "encoding/binary"
+ "github.com/stretchr/testify/mock"
+ "slices"
+ "sync"
+ "sync/atomic"
+)
+
+type ClientID [2]byte
+
+type ClientManager interface {
+ List() []*ClientConn // Returns list of sorted clients
+ Get(id ClientID) *ClientConn
+ Add(cc *ClientConn)
+ Delete(id ClientID)
+}
+
+type MockClientMgr struct {
+ mock.Mock
+}
+
+func (m *MockClientMgr) List() []*ClientConn {
+ args := m.Called()
+
+ return args.Get(0).([]*ClientConn)
+}
+
+func (m *MockClientMgr) Get(id ClientID) *ClientConn {
+ args := m.Called(id)
+
+ return args.Get(0).(*ClientConn)
+}
+
+func (m *MockClientMgr) Add(cc *ClientConn) {
+ m.Called(cc)
+}
+func (m *MockClientMgr) Delete(id ClientID) {
+ m.Called(id)
+}
+
+type MemClientMgr struct {
+ clients map[ClientID]*ClientConn
+
+ mu sync.Mutex
+ nextClientID atomic.Uint32
+}
+
+func NewMemClientMgr() *MemClientMgr {
+ return &MemClientMgr{
+ clients: make(map[ClientID]*ClientConn),
+ }
+}
+
+// List returns slice of sorted clients.
+func (cm *MemClientMgr) List() []*ClientConn {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ var clients []*ClientConn
+ for _, client := range cm.clients {
+ clients = append(clients, client)
+ }
+
+ slices.SortFunc(clients, func(a, b *ClientConn) int {
+ return cmp.Compare(
+ binary.BigEndian.Uint16(a.ID[:]),
+ binary.BigEndian.Uint16(b.ID[:]),
+ )
+ })
+
+ return clients
+}
+
+func (cm *MemClientMgr) Get(id ClientID) *ClientConn {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ return cm.clients[id]
+}
+
+func (cm *MemClientMgr) Add(cc *ClientConn) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ cm.nextClientID.Add(1)
+ binary.BigEndian.PutUint16(cc.ID[:], uint16(cm.nextClientID.Load()))
+
+ cm.clients[cc.ID] = cc
+}
+
+func (cm *MemClientMgr) Delete(id ClientID) {
+ cm.mu.Lock()
+ defer cm.mu.Unlock()
+
+ delete(cm.clients, id)
+}
package hotline
import (
+ "bufio"
+ "bytes"
"encoding/binary"
"errors"
"io"
FieldNewsArtData = [2]byte{0x01, 0x4D} // 333
FieldNewsArtParentArt = [2]byte{0x01, 0x4F} // 335
FieldNewsArt1stChildArt = [2]byte{0x01, 0x50} // 336
+ FieldNewsArtRecurseDel = [2]byte{0x01, 0x51} // 337
// These fields are documented, but seemingly unused.
// FieldUserAlias = [2]byte{0x00, 0x6F} // 111
// FieldNewsArtFlags = [2]byte{0x01, 0x4E} // 334
- // FieldNewsArtRecurseDel = [2]byte{0x01, 0x51} // 337
)
type Field struct {
- ID [2]byte // Type of field
- FieldSize [2]byte // Size of the data part
- Data []byte // Actual field content
+ Type [2]byte // Type of field
+ FieldSize [2]byte // Size of the data field
+ Data []byte // Field data
readOffset int // Internal offset to track read progress
}
-func NewField(id [2]byte, data []byte) Field {
+func NewField(fieldType [2]byte, data []byte) Field {
f := Field{
- ID: id,
+ Type: fieldType,
Data: make([]byte, len(data)),
}
return neededSize, data[0:neededSize], nil
}
+// DecodeInt decodes the field bytes to an int.
+// The official Hotline clients will send uint32s as 2 bytes if possible, but
+// some third party clients such as Frogblast and Heildrun will always send 4 bytes
+func (f *Field) DecodeInt() (int, error) {
+ switch len(f.Data) {
+ case 2:
+ return int(binary.BigEndian.Uint16(f.Data)), nil
+ case 4:
+ return int(binary.BigEndian.Uint32(f.Data)), nil
+ }
+
+ return 0, errors.New("unknown byte length")
+}
+
+func (f *Field) DecodeObfuscatedString() string {
+ return string(encodeString(f.Data))
+}
+
+// DecodeNewsPath decodes the field data to a news path.
+// Example News Path data for a Category nested under two Bundles:
+// 00000000 00 03 00 00 10 54 6f 70 20 4c 65 76 65 6c 20 42 |.....Top Level B|
+// 00000010 75 6e 64 6c 65 00 00 13 53 65 63 6f 6e 64 20 4c |undle...Second L|
+// 00000020 65 76 65 6c 20 42 75 6e 64 6c 65 00 00 0f 4e 65 |evel Bundle...Ne|
+// 00000030 73 74 65 64 20 43 61 74 65 67 6f 72 79 |sted Category|
+func (f *Field) DecodeNewsPath() ([]string, error) {
+ if len(f.Data) == 0 {
+ return []string{}, nil
+ }
+
+ pathCount := binary.BigEndian.Uint16(f.Data[0:2])
+
+ scanner := bufio.NewScanner(bytes.NewReader(f.Data[2:]))
+ scanner.Split(newsPathScanner)
+
+ var paths []string
+
+ for i := uint16(0); i < pathCount; i++ {
+ scanner.Scan()
+ paths = append(paths, scanner.Text())
+ }
+
+ return paths, nil
+}
+
// Read implements io.Reader for Field
func (f *Field) Read(p []byte) (int, error) {
- buf := slices.Concat(f.ID[:], f.FieldSize[:], f.Data)
+ buf := slices.Concat(f.Type[:], f.FieldSize[:], f.Data)
if f.readOffset >= len(buf) {
return 0, io.EOF // All bytes have been read
return 0, errors.New("input slice too short")
}
- copy(f.ID[:], p[0:2])
+ copy(f.Type[:], p[0:2])
copy(f.FieldSize[:], p[2:4])
dataSize := int(binary.BigEndian.Uint16(f.FieldSize[:]))
func getField(id [2]byte, fields *[]Field) *Field {
for _, field := range *fields {
- if id == field.ID {
+ if id == field.Type {
return &field
}
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := &Field{
- ID: tt.fields.ID,
+ Type: tt.fields.ID,
FieldSize: tt.fields.FieldSize,
Data: tt.fields.Data,
readOffset: tt.fields.readOffset,
})
}
}
+
+func TestField_DecodeInt(t *testing.T) {
+ type fields struct {
+ Data []byte
+ }
+ tests := []struct {
+ name string
+ fields fields
+ want int
+ wantErr assert.ErrorAssertionFunc
+ }{
+ {
+ name: "with 2 bytes of input",
+ fields: fields{Data: []byte{0, 1}},
+ want: 1,
+ wantErr: assert.NoError,
+ },
+ {
+ name: "with 4 bytes of input",
+ fields: fields{Data: []byte{0, 1, 0, 0}},
+ want: 65536,
+ wantErr: assert.NoError,
+ },
+ {
+ name: "with invalid number of bytes of input",
+ fields: fields{Data: []byte{1, 0, 0, 0, 0, 0, 0, 0}},
+ want: 0,
+ wantErr: assert.Error,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ f := &Field{
+ Data: tt.fields.Data,
+ }
+ got, err := f.DecodeInt()
+ if !tt.wantErr(t, err, fmt.Sprintf("DecodeInt()")) {
+ return
+ }
+ assert.Equalf(t, tt.want, got, "DecodeInt()")
+ })
+ }
+}
"sync"
)
+// Folder download actions. Send by the client to indicate the next action the server should take
+// for a folder download.
+const (
+ DlFldrActionSendFile = 1
+ DlFldrActionResumeFile = 2
+ DlFldrActionNextFile = 3
+)
+
// File transfer types
+type FileTransferType uint8
+
const (
- FileDownload = iota
- FileUpload
- FolderDownload
- FolderUpload
- bannerDownload
+ FileDownload = FileTransferType(0)
+ FileUpload = FileTransferType(1)
+ FolderDownload = FileTransferType(2)
+ FolderUpload = FileTransferType(3)
+ BannerDownload = FileTransferType(4)
)
+type FileTransferID [4]byte
+
+type FileTransferMgr interface {
+ Add(ft *FileTransfer)
+ Get(id FileTransferID) *FileTransfer
+ Delete(id FileTransferID)
+}
+
+type MemFileTransferMgr struct {
+ fileTransfers map[FileTransferID]*FileTransfer
+
+ mu sync.Mutex
+}
+
+func NewMemFileTransferMgr() *MemFileTransferMgr {
+ return &MemFileTransferMgr{
+ fileTransfers: make(map[FileTransferID]*FileTransfer),
+ }
+}
+
+func (ftm *MemFileTransferMgr) Add(ft *FileTransfer) {
+ ftm.mu.Lock()
+ defer ftm.mu.Unlock()
+
+ _, _ = rand.Read(ft.refNum[:])
+
+ 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 {
+ ftm.mu.Lock()
+ defer ftm.mu.Unlock()
+
+ return ftm.fileTransfers[id]
+}
+
+func (ftm *MemFileTransferMgr) Delete(id FileTransferID) {
+ ftm.mu.Lock()
+ defer ftm.mu.Unlock()
+
+ 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
- Type int
+ Type FileTransferType
TransferSize []byte
FolderItemCount []byte
fileResumeData *FileResumeData
return n, nil
}
-func (cc *ClientConn) newFileTransfer(transferType int, 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
- _, _ = 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
+ //cc.Server.mux.Lock()
+ //defer cc.Server.mux.Unlock()
+ //cc.Server.fileTransfers[ft.refNum] = ft
return ft
}
return filepath.Join(pathSegments...)
}
-func DownloadHandler(rwc io.ReadWriter, fullPath string, fileTransfer *FileTransfer, fs FileStore, rLogger *slog.Logger, preserveForks bool) error {
+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
//defer func() {
fw, err := newFileWrapper(fs, fullPath, 0)
if err != nil {
- //return err
+ return fmt.Errorf("reading file header: %v", err)
}
- rLogger.Info("File download started", "filePath", fullPath)
+ rLogger.Info("Download file", "filePath", fullPath)
- // if file transfer options are included, that means this is a "quick preview" request from a 1.5+ client
+ // 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 {
- _, err = io.Copy(rwc, fw.ffo)
- if err != nil {
- //return err
+ if _, err = io.Copy(w, fw.ffo); err != nil {
+ return fmt.Errorf("send flat file object: %v", err)
}
}
file, err := fw.dataForkReader()
if err != nil {
- //return err
+ return fmt.Errorf("open data fork reader: %v", err)
}
br := bufio.NewReader(file)
if _, err := br.Discard(int(dataOffset)); err != nil {
- //return err
+ return fmt.Errorf("seek to resume offsent: %v", err)
}
- if _, err = io.Copy(rwc, io.TeeReader(br, fileTransfer.bytesSentCounter)); err != nil {
- return err
+ if _, err = io.Copy(w, io.TeeReader(br, fileTransfer.bytesSentCounter)); err != nil {
+ return fmt.Errorf("send data fork: %v", err)
}
- // if the client requested to resume transfer, do not send the resource fork header, or it will be appended into the fileWrapper data
+ // If the client requested to resume transfer, do not send the resource fork header.
if fileTransfer.fileResumeData == nil {
- err = binary.Write(rwc, binary.BigEndian, fw.rsrcForkHeader())
+ err = binary.Write(w, binary.BigEndian, fw.rsrcForkHeader())
if err != nil {
- return err
+ return fmt.Errorf("send resource fork header: %v", err)
}
}
rFile, err := fw.rsrcForkFile()
if err != nil {
- return nil
+ // return fmt.Errorf("open resource fork file: %v", err)
}
- if _, err = io.Copy(rwc, io.TeeReader(rFile, fileTransfer.bytesSentCounter)); err != nil {
- return err
+ if _, err = io.Copy(w, io.TeeReader(rFile, fileTransfer.bytesSentCounter)); err != nil {
+ // return fmt.Errorf("send resource fork data: %v", err)
}
return nil
var dataOffset int64
switch nextAction[1] {
- case dlFldrActionResumeFile:
+ case DlFldrActionResumeFile:
// get size of resumeData
resumeDataByteLen := make([]byte, 2)
if _, err := io.ReadFull(rwc, resumeDataByteLen); err != nil {
return err
}
dataOffset = int64(binary.BigEndian.Uint32(frd.ForkInfoList[0].DataSize[:]))
- case dlFldrActionNextFile:
+ case DlFldrActionNextFile:
// client asked to skip this file
return nil
}
}
// Begin the folder upload flow by sending the "next file action" to client
- if _, err := rwc.Write([]byte{0, dlFldrActionNextFile}); err != nil {
+ if _, err := rwc.Write([]byte{0, DlFldrActionNextFile}); err != nil {
return err
}
}
// Tell client to send next file
- if _, err := rwc.Write([]byte{0, dlFldrActionNextFile}); err != nil {
+ if _, err := rwc.Write([]byte{0, DlFldrActionNextFile}); err != nil {
return err
}
} else {
- nextAction := dlFldrActionSendFile
+ nextAction := DlFldrActionSendFile
// Check if we have the full file already. If so, send dlFldrAction_NextFile to client to skip.
_, err := os.Stat(filepath.Join(fullPath, fu.FormattedPath()))
return err
}
if err == nil {
- nextAction = dlFldrActionNextFile
+ nextAction = DlFldrActionNextFile
}
// Check if we have a partial file already. If so, send dlFldrAction_ResumeFile to client to resume upload.
return err
}
if err == nil {
- nextAction = dlFldrActionResumeFile
+ nextAction = DlFldrActionResumeFile
}
if _, err := rwc.Write([]byte{0, uint8(nextAction)}); err != nil {
}
switch nextAction {
- case dlFldrActionNextFile:
+ case DlFldrActionNextFile:
continue
- case dlFldrActionResumeFile:
+ case DlFldrActionResumeFile:
offset := make([]byte, 4)
binary.BigEndian.PutUint32(offset, uint32(incompleteFile.Size()))
return err
}
- case dlFldrActionSendFile:
+ case DlFldrActionSendFile:
if _, err := io.ReadFull(rwc, fileSize); err != nil {
return err
}
}
// Tell client to send next fileWrapper
- if _, err := rwc.Write([]byte{0, dlFldrActionNextFile}); err != nil {
+ if _, err := rwc.Write([]byte{0, DlFldrActionNextFile}); err != nil {
return err
}
}
FileName []byte
FilePath []byte
refNum [4]byte
- Type int
+ Type FileTransferType
TransferSize []byte
FolderItemCount []byte
fileResumeData *FileResumeData
//
// The following information is sent to the server:
// Description Size Data Note
-// Protocol ID 4 TRTP 0x54525450
-// Sub-protocol ID 4 HOTL User defined
+// Protocol Type 4 TRTP 0x54525450
+// Sub-protocol Type 4 HOTL User defined
// VERSION 2 1 Currently 1
// Sub-version 2 2 User defined
//
// The server replies with the following:
// Description Size Data Note
-// Protocol ID 4 TRTP
+// Protocol Type 4 TRTP
// Error code 4 Error code returned by the server (0 = no error)
type handshake struct {
}
var (
- // trtp represents the Protocol ID "TRTP" in hex
+ // trtp represents the Protocol Type "TRTP" in hex
trtp = [4]byte{0x54, 0x52, 0x54, 0x50}
- // hotl represents the Sub-protocol ID "HOTL" in hex
+ // hotl represents the Sub-protocol Type "HOTL" in hex
hotl = [4]byte{0x48, 0x4F, 0x54, 0x4C}
// handshakeResponse represents the server's response after a successful handshake
--- /dev/null
+package hotline
+
+const defaultNewsDateFormat = "Jan02 15:04" // Jun23 20:49
+
+const defaultNewsTemplate = `From %s (%s):
+
+%s
+
+__________________________________________________________`
--- /dev/null
+package hotline
+
+import "github.com/stretchr/testify/mock"
+
+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)
+}
package hotline
import (
+ "cmp"
"encoding/binary"
"io"
"slices"
- "sort"
)
-const defaultNewsDateFormat = "Jan02 15:04" // Jun23 20:49
-
-const defaultNewsTemplate = `From %s (%s):
-
-%s
+var (
+ NewsBundle = [2]byte{0, 2}
+ NewsCategory = [2]byte{0, 3}
+)
-__________________________________________________________`
+type ThreadedNewsMgr interface {
+ ListArticles(newsPath []string) NewsArtListData
+ GetArticle(newsPath []string, articleID uint32) *NewsArtData
+ DeleteArticle(newsPath []string, articleID uint32, recursive bool) error
+ PostArticle(newsPath []string, parentArticleID uint32, article NewsArtData) error
+ CreateGrouping(newsPath []string, name string, t [2]byte) error
+ GetCategories(paths []string) []NewsCategoryListData15
+ NewsItem(newsPath []string) NewsCategoryListData15
+ DeleteNewsItem(newsPath []string) error
+}
-// ThreadedNews is the top level struct containing all threaded news categories, bundles, and articles
+// ThreadedNews contains the top level of threaded news categories, bundles, and articles.
type ThreadedNews struct {
Categories map[string]NewsCategoryListData15 `yaml:"Categories"`
}
id := make([]byte, 4)
binary.BigEndian.PutUint32(id, i)
- newArt := NewsArtList{
+ newsArts = append(newsArts, NewsArtList{
ID: [4]byte(id),
TimeStamp: art.Date,
ParentID: art.ParentArt,
Title: []byte(art.Title),
Poster: []byte(art.Poster),
ArticleSize: art.DataSize(),
- }
-
- newsArts = append(newsArts, newArt)
+ })
}
- sort.Sort(byID(newsArts))
+ // Sort the articles by ID. This is important for displaying the message threading correctly on the client side.
+ slices.SortFunc(newsArts, func(a, b NewsArtList) int {
+ return cmp.Compare(
+ binary.BigEndian.Uint32(a.ID[:]),
+ binary.BigEndian.Uint32(b.ID[:]),
+ )
+ })
for _, v := range newsArts {
b, err := io.ReadAll(&v)
}
}
-// NewsArtData represents single news article
+// NewsArtData represents an individual news article.
type NewsArtData struct {
Title string `yaml:"Title"`
Poster string `yaml:"Poster"`
NextArt [4]byte `yaml:"NextArt,flow"`
ParentArt [4]byte `yaml:"ParentArt,flow"`
FirstChildArt [4]byte `yaml:"FirstChildArtArt,flow"`
- DataFlav []byte `yaml:"-"` // "text/plain"
+ DataFlav []byte `yaml:"-"` // MIME type string. Always "text/plain".
Data string `yaml:"Data"`
}
-func (art *NewsArtData) DataSize() []byte {
+func (art *NewsArtData) DataSize() [2]byte {
dataLen := make([]byte, 2)
binary.BigEndian.PutUint16(dataLen, uint16(len(art.Data)))
- return dataLen
+ return [2]byte(dataLen)
}
type NewsArtListData struct {
- ID [4]byte `yaml:"ID"`
+ ID [4]byte `yaml:"Type"`
Name []byte `yaml:"Name"`
Description []byte `yaml:"Description"` // not used?
NewsArtList []byte // List of articles Optional (if article count > 0)
Poster []byte
FlavorList []NewsFlavorList
// Flavor list… Optional (if flavor count > 0)
- ArticleSize []byte // Size 2
+ ArticleSize [2]byte // Size 2
readOffset int // Internal offset to track read progress
}
-type byID []NewsArtList
-
-func (s byID) Len() int {
- return len(s)
-}
-func (s byID) Swap(i, j int) {
- s[i], s[j] = s[j], s[i]
-}
-func (s byID) Less(i, j int) bool {
- return binary.BigEndian.Uint32(s[i].ID[:]) < binary.BigEndian.Uint32(s[j].ID[:])
-}
-
var (
NewsFlavorLen = []byte{0x0a}
NewsFlavor = []byte("text/plain")
nal.Poster,
NewsFlavorLen,
NewsFlavor,
- nal.ArticleSize,
+ nal.ArticleSize[:],
)
if nal.readOffset >= len(out) {
newscat.Type[:],
count,
)
-
- // If type is category
- if newscat.Type == [2]byte{0, 3} {
- out = append(out, newscat.GUID[:]...)
- out = append(out, newscat.AddSN[:]...)
- out = append(out, newscat.DeleteSN[:]...)
+ if newscat.Type == NewsCategory {
+ out = slices.Concat(out,
+ newscat.GUID[:],
+ newscat.AddSN[:],
+ newscat.DeleteSN[:],
+ )
}
-
- out = append(out, newscat.nameLen()...)
- out = append(out, []byte(newscat.Name)...)
+ out = slices.Concat(out,
+ newscat.nameLen(),
+ []byte(newscat.Name),
+ )
if newscat.readOffset >= len(out) {
return 0, io.EOF // All bytes have been read
}
n := copy(p, out)
+
newscat.readOffset = n
return n, nil
return []byte{uint8(len(newscat.Name))}
}
-// TODO: re-implement as bufio.Scanner interface
-func ReadNewsPath(newsPath []byte) []string {
- if len(newsPath) == 0 {
- return []string{}
+// newsPathScanner implements bufio.SplitFunc for parsing incoming byte slices into complete tokens
+func newsPathScanner(data []byte, _ bool) (advance int, token []byte, err error) {
+ if len(data) < 3 {
+ return 0, nil, nil
}
- pathCount := binary.BigEndian.Uint16(newsPath[0:2])
-
- pathData := newsPath[2:]
- var paths []string
-
- for i := uint16(0); i < pathCount; i++ {
- pathLen := pathData[2]
- paths = append(paths, string(pathData[3:3+pathLen]))
- pathData = pathData[pathLen+3:]
- }
-
- return paths
-}
-
-func (s *Server) GetNewsCatByPath(paths []string) map[string]NewsCategoryListData15 {
- cats := s.ThreadedNews.Categories
- for _, path := range paths {
- cats = cats[path].SubCats
- }
- return cats
+ advance = 3 + int(data[2])
+ return advance, data[3:advance], nil
}
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
"encoding/binary"
"errors"
"fmt"
- "github.com/go-playground/validator/v10"
"golang.org/x/text/encoding/charmap"
"gopkg.in/yaml.v3"
"io"
"log/slog"
"net"
"os"
- "path"
"path/filepath"
"strings"
"sync"
- "sync/atomic"
"time"
)
type Server struct {
NetInterface string
Port int
- Accounts map[string]*Account
- Agreement []byte
- Clients map[[2]byte]*ClientConn
- fileTransfers map[[4]byte]*FileTransfer
-
- Config *Config
+ Config Config
ConfigDir string
Logger *slog.Logger
- banner []byte
- PrivateChatsMu sync.Mutex
- PrivateChats map[[4]byte]*PrivateChat
-
- nextClientID atomic.Uint32
TrackerPassID [4]byte
- statsMu sync.Mutex
- Stats *Stats
+ Stats Counter
FS FileStore // Storage backend to use for File storage
outbox chan Transaction
- mux sync.Mutex
- threadedNewsMux sync.Mutex
- ThreadedNews *ThreadedNews
+ // TODO
+ Agreement []byte
+ banner []byte
+ // END TODO
- flatNewsMux sync.Mutex
- FlatNews []byte
+ FileTransferMgr FileTransferMgr
+ ChatMgr ChatManager
+ ClientMgr ClientManager
+ AccountManager AccountManager
+ ThreadedNewsMgr ThreadedNewsMgr
+ BanList BanMgr
- banListMU sync.Mutex
- banList map[string]*time.Time
+ MessageBoard io.ReadWriteSeeker
}
-func (s *Server) CurrentStats() Stats {
- s.statsMu.Lock()
- defer s.statsMu.Unlock()
+// 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(),
+ }
+
+ // generate a new random passID for tracker registration
+ _, err := rand.Read(server.TrackerPassID[:])
+ if err != nil {
+ return nil, err
+ }
+
+ server.Agreement, err = os.ReadFile(filepath.Join(configDir, agreementFile))
+ if err != nil {
+ return nil, err
+ }
+
+ server.AccountManager, err = NewYAMLAccountManager(filepath.Join(configDir, "Users/"))
+ if err != nil {
+ return nil, fmt.Errorf("error loading accounts: %w", err)
+ }
+
+ // 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)
+ }
+
+ server.banner, err = os.ReadFile(filepath.Join(server.ConfigDir, server.Config.BannerFile))
+ if err != nil {
+ return nil, fmt.Errorf("error opening banner: %w", err)
+ }
+
+ if server.Config.EnableTrackerRegistration {
+ server.Logger.Info(
+ "Tracker registration enabled",
+ "frequency", fmt.Sprintf("%vs", trackerUpdateFrequency),
+ "trackers", server.Config.Trackers,
+ )
+
+ go server.registerWithTrackers()
+ }
- stats := s.Stats
- stats.CurrentlyConnected = len(s.Clients)
+ // Start Client Keepalive go routine
+ go server.keepaliveHandler()
- return *stats
+ return &server, nil
}
-type PrivateChat struct {
- Subject string
- ClientConn map[[2]byte]*ClientConn
+func (s *Server) CurrentStats() map[string]interface{} {
+ return s.Stats.Values()
}
func (s *Server) ListenAndServe(ctx context.Context) error {
}
func (s *Server) sendTransaction(t Transaction) error {
- s.mux.Lock()
- client, ok := s.Clients[t.clientID]
- s.mux.Unlock()
+ client := s.ClientMgr.Get(t.clientID)
- if !ok || client == nil {
+ if client == nil {
return nil
}
agreementFile = "Agreement.txt"
)
-// NewServer constructs a new Server from a config dir
-// TODO: move config file reads out of this function
-func NewServer(configDir, netInterface string, netPort int, logger *slog.Logger, fs FileStore) (*Server, error) {
- server := Server{
- NetInterface: netInterface,
- Port: netPort,
- Accounts: make(map[string]*Account),
- Config: new(Config),
- Clients: make(map[[2]byte]*ClientConn),
- fileTransfers: make(map[[4]byte]*FileTransfer),
- PrivateChats: make(map[[4]byte]*PrivateChat),
- ConfigDir: configDir,
- Logger: logger,
- outbox: make(chan Transaction),
- Stats: &Stats{Since: time.Now()},
- ThreadedNews: &ThreadedNews{},
- FS: fs,
- banList: make(map[string]*time.Time),
- }
-
- var err error
-
- // generate a new random passID for tracker registration
- if _, err := rand.Read(server.TrackerPassID[:]); err != nil {
- return nil, err
- }
-
- server.Agreement, err = os.ReadFile(filepath.Join(configDir, agreementFile))
- if err != nil {
- return nil, err
- }
-
- if server.FlatNews, err = os.ReadFile(filepath.Join(configDir, "MessageBoard.txt")); err != nil {
- return nil, err
- }
-
- // try to load the ban list, but ignore errors as this file may not be present or may be empty
- //_ = server.loadBanList(filepath.Join(configDir, "Banlist.yaml"))
-
- _ = loadFromYAMLFile(filepath.Join(configDir, "Banlist.yaml"), &server.banList)
-
- err = loadFromYAMLFile(filepath.Join(configDir, "ThreadedNews.yaml"), &server.ThreadedNews)
- if err != nil {
- return nil, fmt.Errorf("error loading threaded news: %w", err)
- }
-
- err = server.loadConfig(filepath.Join(configDir, "config.yaml"))
- if err != nil {
- return nil, fmt.Errorf("error loading config: %w", err)
- }
-
- if err := server.loadAccounts(filepath.Join(configDir, "Users/")); err != nil {
- return nil, err
- }
-
- // 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)
- }
-
- server.banner, err = os.ReadFile(filepath.Join(server.ConfigDir, server.Config.BannerFile))
- if err != nil {
- return nil, fmt.Errorf("error opening banner: %w", err)
- }
-
- if server.Config.EnableTrackerRegistration {
- server.Logger.Info(
- "Tracker registration enabled",
- "frequency", fmt.Sprintf("%vs", trackerUpdateFrequency),
- "trackers", server.Config.Trackers,
- )
-
- go func() {
- for {
- tr := &TrackerRegistration{
- UserCount: server.userCount(),
- PassID: server.TrackerPassID,
- Name: server.Config.Name,
- Description: server.Config.Description,
- }
- binary.BigEndian.PutUint16(tr.Port[:], uint16(server.Port))
- for _, t := range server.Config.Trackers {
- if err := register(&RealDialer{}, t, tr); err != nil {
- server.Logger.Error("unable to register with tracker %v", "error", err)
- }
- server.Logger.Debug("Sent Tracker registration", "addr", t)
- }
-
- time.Sleep(trackerUpdateFrequency * time.Second)
+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)
}
- }()
- }
-
- // Start Client Keepalive go routine
- go server.keepaliveHandler()
-
- return &server, nil
-}
-
-func (s *Server) userCount() int {
- s.mux.Lock()
- defer s.mux.Unlock()
+ }
- return len(s.Clients)
+ time.Sleep(trackerUpdateFrequency * time.Second)
+ }
}
+// keepaliveHandler
func (s *Server) keepaliveHandler() {
for {
time.Sleep(idleCheckInterval * time.Second)
- s.mux.Lock()
- for _, c := range s.Clients {
+ for _, c := range s.ClientMgr.List() {
+ c.mu.Lock()
+
c.IdleTime += idleCheckInterval
- if c.IdleTime > userIdleSeconds && !c.Idle {
- c.Idle = true
- c.flagsMU.Lock()
+ // Check if the user
+ if c.IdleTime > userIdleSeconds && !c.Flags.IsSet(UserFlagAway) {
c.Flags.Set(UserFlagAway, 1)
- c.flagsMU.Unlock()
- c.sendAll(
+
+ c.SendAll(
TranNotifyChangeUser,
NewField(FieldUserID, c.ID[:]),
NewField(FieldUserFlags, c.Flags[:]),
NewField(FieldUserIconID, c.Icon),
)
}
+ c.mu.Unlock()
}
- s.mux.Unlock()
- }
-}
-
-func (s *Server) writeBanList() error {
- s.banListMU.Lock()
- defer s.banListMU.Unlock()
-
- out, err := yaml.Marshal(s.banList)
- if err != nil {
- return err
}
- err = os.WriteFile(
- filepath.Join(s.ConfigDir, "Banlist.yaml"),
- out,
- 0666,
- )
- return err
-}
-
-func (s *Server) writeThreadedNews() error {
- s.threadedNewsMux.Lock()
- defer s.threadedNewsMux.Unlock()
-
- out, err := yaml.Marshal(s.ThreadedNews)
- if err != nil {
- return err
- }
- err = s.FS.WriteFile(
- filepath.Join(s.ConfigDir, "ThreadedNews.yaml"),
- out,
- 0666,
- )
- return err
}
func (s *Server) NewClientConn(conn io.ReadWriteCloser, remoteAddr string) *ClientConn {
- s.mux.Lock()
- defer s.mux.Unlock()
-
clientConn := &ClientConn{
Icon: []byte{0, 0}, // TODO: make array type
Connection: conn,
Server: s,
RemoteAddr: remoteAddr,
- transfers: map[int]map[[4]byte]*FileTransfer{
- FileDownload: {},
- FileUpload: {},
- FolderDownload: {},
- FolderUpload: {},
- bannerDownload: {},
- },
- }
-
- s.nextClientID.Add(1)
-
- binary.BigEndian.PutUint16(clientConn.ID[:], uint16(s.nextClientID.Load()))
- s.Clients[clientConn.ID] = clientConn
-
- return clientConn
-}
-
-// NewUser creates a new user account entry in the server map and config file
-func (s *Server) NewUser(login, name, password string, access accessBitmap) error {
- s.mux.Lock()
- defer s.mux.Unlock()
-
- account := NewAccount(login, name, password, access)
-
- // Create account file, returning an error if one already exists.
- file, err := os.OpenFile(
- filepath.Join(s.ConfigDir, "Users", path.Join("/", 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 err
- }
-
- _, err = file.Write(b)
- if err != nil {
- return fmt.Errorf("error writing account file: %w", err)
- }
-
- s.Accounts[login] = account
-
- return nil
-}
-
-func (s *Server) UpdateUser(login, newLogin, name, password string, access accessBitmap) error {
- s.mux.Lock()
- defer s.mux.Unlock()
-
- // If the login has changed, rename the account file.
- if login != newLogin {
- err := os.Rename(
- filepath.Join(s.ConfigDir, "Users", path.Join("/", login)+".yaml"),
- filepath.Join(s.ConfigDir, "Users", path.Join("/", newLogin)+".yaml"),
- )
- if err != nil {
- return fmt.Errorf("error renaming account file: %w", err)
- }
- s.Accounts[newLogin] = s.Accounts[login]
- s.Accounts[newLogin].Login = newLogin
- delete(s.Accounts, login)
- }
-
- account := s.Accounts[newLogin]
- account.Access = access
- account.Name = name
- account.Password = password
-
- out, err := yaml.Marshal(&account)
- if err != nil {
- return err
- }
-
- if err := os.WriteFile(filepath.Join(s.ConfigDir, "Users", newLogin+".yaml"), out, 0666); err != nil {
- return fmt.Errorf("error writing account file: %w", err)
- }
-
- return nil
-}
-// DeleteUser deletes the user account
-func (s *Server) DeleteUser(login string) error {
- s.mux.Lock()
- defer s.mux.Unlock()
-
- err := s.FS.Remove(filepath.Join(s.ConfigDir, "Users", path.Join("/", login)+".yaml"))
- if err != nil {
- return err
+ ClientFileTransferMgr: NewClientFileTransferMgr(),
}
- delete(s.Accounts, login)
+ s.ClientMgr.Add(clientConn)
- return nil
-}
-
-func (s *Server) connectedUsers() []Field {
- //s.mux.Lock()
- //defer s.mux.Unlock()
-
- var connectedUsers []Field
- for _, c := range sortedClients(s.Clients) {
- b, err := io.ReadAll(&User{
- ID: c.ID,
- Icon: c.Icon,
- Flags: c.Flags[:],
- Name: string(c.UserName),
- })
- if err != nil {
- return nil
- }
- connectedUsers = append(connectedUsers, NewField(FieldUsernameWithInfo, b))
- }
- return connectedUsers
+ return clientConn
}
// loadFromYAMLFile loads data from a YAML file into the provided data structure.
return decoder.Decode(data)
}
-// loadAccounts loads account data from disk
-func (s *Server) loadAccounts(userDir string) error {
- matches, err := filepath.Glob(filepath.Join(userDir, "*.yaml"))
- if err != nil {
- return err
- }
-
- if len(matches) == 0 {
- return fmt.Errorf("no accounts found in directory: %s", userDir)
- }
-
- for _, file := range matches {
- var account Account
- if err = loadFromYAMLFile(file, &account); err != nil {
- return fmt.Errorf("error loading account %s: %w", file, err)
- }
-
- s.Accounts[account.Login] = &account
- }
- return nil
-}
-
-func (s *Server) loadConfig(path string) error {
- fh, err := s.FS.Open(path)
- if err != nil {
- return err
- }
-
- decoder := yaml.NewDecoder(fh)
- err = decoder.Decode(s.Config)
- if err != nil {
- return err
- }
-
- validate := validator.New()
- err = validate.Struct(s.Config)
- if err != nil {
- return err
- }
- return nil
-}
-
func sendBanMessage(rwc io.Writer, message string) {
t := NewTransaction(
TranServerMsg,
// Check if remoteAddr is present in the ban list
ipAddr := strings.Split(remoteAddr, ":")[0]
- if banUntil, ok := s.banList[ipAddr]; ok {
+ if isBanned, banUntil := s.BanList.IsBanned(ipAddr); isBanned {
// permaban
if banUntil == nil {
sendBanMessage(rwc, "You are permanently banned on this server")
encodedPassword := clientLogin.GetField(FieldUserPassword).Data
c.Version = clientLogin.GetField(FieldVersion).Data
- login := string(encodeString(clientLogin.GetField(FieldUserLogin).Data))
+ login := clientLogin.GetField(FieldUserLogin).DecodeObfuscatedString()
if login == "" {
login = GuestAccount
}
- c.logger = s.Logger.With("remoteAddr", remoteAddr, "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) {
c.Icon = clientLogin.GetField(FieldUserIconID).Data
}
- c.Lock()
- c.Account = c.Server.Accounts[login]
- c.Unlock()
+ c.Account = c.Server.AccountManager.Get(login)
+ if c.Account == nil {
+ return nil
+ }
if clientLogin.GetField(FieldUserName).Data != nil {
- if c.Authorize(accessAnyName) {
+ if c.Authorize(AccessAnyName) {
c.UserName = clientLogin.GetField(FieldUserName).Data
} else {
c.UserName = []byte(c.Account.Name)
}
}
- if c.Authorize(accessDisconUser) {
+ if c.Authorize(AccessDisconUser) {
c.Flags.Set(UserFlagAdmin, 1)
}
// Send user access privs so client UI knows how to behave
c.Server.outbox <- NewTransaction(TranUserAccess, c.ID, NewField(FieldUserAccess, c.Account.Access[:]))
- // Accounts with accessNoAgreement do not receive the server agreement on login. The behavior is different between
+ // Accounts with AccessNoAgreement do not receive the server agreement on login. The behavior is different between
// client versions. For 1.2.3 client, we do not send TranShowAgreement. For other client versions, we send
// TranShowAgreement but with the NoServerAgreement field set to 1.
- if c.Authorize(accessNoAgreement) {
+ if c.Authorize(AccessNoAgreement) {
// If client version is nil, then the client uses the 1.2.3 login behavior
if c.Version != nil {
c.Server.outbox <- NewTransaction(TranShowAgreement, c.ID, NewField(FieldNoServerAgreement, []byte{1}))
// 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
- for _, t := range c.notifyOthers(
+ for _, t := range c.NotifyOthers(
NewTransaction(
TranNotifyChangeUser, [2]byte{0, 0},
NewField(FieldUserName, c.UserName),
}
}
- c.Server.mux.Lock()
- c.Server.Stats.ConnectionCounter += 1
- if len(s.Clients) > c.Server.Stats.ConnectionPeak {
- c.Server.Stats.ConnectionPeak = len(s.Clients)
+ c.Server.Stats.Increment(StatConnectionCounter, StatCurrentlyConnected)
+ defer c.Server.Stats.Decrement(StatCurrentlyConnected)
+
+ if len(s.ClientMgr.List()) > c.Server.Stats.Get(StatConnectionPeak) {
+ c.Server.Stats.Set(StatConnectionPeak, len(s.ClientMgr.List()))
}
- c.Server.mux.Unlock()
// Scan for new transactions and handle them as they come in.
for scanner.Scan() {
return nil
}
-func (s *Server) NewPrivateChat(cc *ClientConn) [4]byte {
- s.PrivateChatsMu.Lock()
- defer s.PrivateChatsMu.Unlock()
-
- var randID [4]byte
- _, _ = rand.Read(randID[:])
-
- s.PrivateChats[randID] = &PrivateChat{
- ClientConn: make(map[[2]byte]*ClientConn),
- }
- s.PrivateChats[randID].ClientConn[cc.ID] = cc
-
- return randID
-}
-
-const dlFldrActionSendFile = 1
-const dlFldrActionResumeFile = 2
-const dlFldrActionNextFile = 3
-
// handleFileTransfer receives a client net.Conn from the file transfer server, performs the requested transfer type, then closes the connection
func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) error {
defer dontPanic(s.Logger)
return fmt.Errorf("error reading file transfer: %w", err)
}
+ fileTransfer := s.FileTransferMgr.Get(t.ReferenceNumber)
+ if fileTransfer == nil {
+ return errors.New("invalid transaction ID")
+ }
+
defer func() {
- s.mux.Lock()
- delete(s.fileTransfers, t.ReferenceNumber)
- s.mux.Unlock()
+ s.FileTransferMgr.Delete(t.ReferenceNumber)
// Wait a few seconds before closing the connection: this is a workaround for problems
// observed with Windows clients where the client must initiate close of the TCP connection before
time.Sleep(3 * time.Second)
}()
- s.mux.Lock()
- fileTransfer, ok := s.fileTransfers[t.ReferenceNumber]
- s.mux.Unlock()
- if !ok {
- return errors.New("invalid transaction ID")
- }
-
- defer func() {
- fileTransfer.ClientConn.transfersMU.Lock()
- delete(fileTransfer.ClientConn.transfers[fileTransfer.Type], t.ReferenceNumber)
- fileTransfer.ClientConn.transfersMU.Unlock()
- }()
-
rLogger := s.Logger.With(
"remoteAddr", ctx.Value(contextKeyReq).(requestCtx).remoteAddr,
"login", fileTransfer.ClientConn.Account.Login,
}
switch fileTransfer.Type {
- case bannerDownload:
+ case BannerDownload:
if _, err := io.Copy(rwc, bytes.NewBuffer(s.banner)); err != nil {
return fmt.Errorf("error sending banner: %w", err)
}
case FileDownload:
- s.Stats.DownloadCounter += 1
- s.Stats.DownloadsInProgress += 1
+ s.Stats.Increment(StatDownloadCounter, StatDownloadsInProgress)
defer func() {
- s.Stats.DownloadsInProgress -= 1
+ s.Stats.Decrement(StatDownloadsInProgress)
}()
err = DownloadHandler(rwc, fullPath, fileTransfer, s.FS, rLogger, true)
if err != nil {
- return fmt.Errorf("file download error: %w", err)
+ return fmt.Errorf("file download: %w", err)
}
case FileUpload:
- s.Stats.UploadCounter += 1
- s.Stats.UploadsInProgress += 1
- defer func() { s.Stats.UploadsInProgress -= 1 }()
+ s.Stats.Increment(StatUploadCounter, StatUploadsInProgress)
+ defer func() {
+ s.Stats.Decrement(StatUploadsInProgress)
+ }()
err = UploadHandler(rwc, fullPath, fileTransfer, s.FS, rLogger, s.Config.PreserveResourceForks)
if err != nil {
}
case FolderDownload:
- s.Stats.DownloadCounter += 1
- s.Stats.DownloadsInProgress += 1
- defer func() { s.Stats.DownloadsInProgress -= 1 }()
+ s.Stats.Increment(StatDownloadCounter, StatDownloadsInProgress)
+ defer func() {
+ s.Stats.Decrement(StatDownloadsInProgress)
+ }()
err = DownloadFolderHandler(rwc, fullPath, fileTransfer, s.FS, rLogger, s.Config.PreserveResourceForks)
if err != nil {
}
case FolderUpload:
- s.Stats.UploadCounter += 1
- s.Stats.UploadsInProgress += 1
- defer func() { s.Stats.UploadsInProgress -= 1 }()
+ s.Stats.Increment(StatUploadCounter, StatUploadsInProgress)
+ defer func() {
+ s.Stats.Decrement(StatUploadsInProgress)
+ }()
+
rLogger.Info(
"Folder upload started",
"dstPath", fullPath,
return true
}
- var clean []byte
- clean = append(clean, got[:92]...) // keep the first 92 bytes
- clean = append(clean, make([]byte, 16)...) // replace the next 16 bytes for create/modify timestamps
- clean = append(clean, got[108:]...) // keep the rest
-
+ clean := slices.Concat(
+ got[:92],
+ make([]byte, 16),
+ got[108:],
+ )
return assert.Equal(t, wantHexDump, hex.Dump(clean))
}
)
}
-// tranAssertEqual compares equality of transactions slices after stripping out the random transaction ID
+// 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
trans.ID = [4]byte{0, 0, 0, 0}
var fs []Field
for _, field := range trans.Fields {
- if field.ID == [2]byte{0x00, 0x6b} { // FieldRefNum
+ if field.Type == FieldRefNum { // FieldRefNum
continue
}
- if field.ID == [2]byte{0x00, 0x72} { // FieldChatID
+ if field.Type == FieldChatID { // FieldChatID
continue
}
+
fs = append(fs, field)
}
trans.Fields = fs
trans.ID = [4]byte{0, 0, 0, 0}
var fs []Field
for _, field := range trans.Fields {
- if field.ID == [2]byte{0x00, 0x6b} { // FieldRefNum
+ if field.Type == FieldRefNum { // FieldRefNum
continue
}
- if field.ID == [2]byte{0x00, 0x72} { // FieldChatID
+ if field.Type == FieldChatID { // FieldChatID
continue
}
+
fs = append(fs, field)
}
trans.Fields = fs
"io"
"log/slog"
"os"
- "sync"
"testing"
)
func TestServer_handleFileTransfer(t *testing.T) {
type fields struct {
- Port int
- Accounts map[string]*Account
- Agreement []byte
- Clients map[[2]byte]*ClientConn
- ThreadedNews *ThreadedNews
- fileTransfers map[[4]byte]*FileTransfer
- Config *Config
- ConfigDir string
- Logger *slog.Logger
- PrivateChats map[uint32]*PrivateChat
- NextGuestID *uint16
- TrackerPassID [4]byte
- Stats *Stats
- FS FileStore
- FlatNews []byte
+ ThreadedNews *ThreadedNews
+ FileTransferMgr FileTransferMgr
+ Config Config
+ ConfigDir string
+ Stats *Stats
+ Logger *slog.Logger
+ FS FileStore
}
type args struct {
ctx context.Context
wantErr: assert.Error,
},
{
- name: "with invalid transfer ID",
+ name: "with invalid transfer Type",
+ fields: fields{
+ FileTransferMgr: NewMemFileTransferMgr(),
+ },
args: args{
ctx: func() context.Context {
ctx := context.Background()
name: "file download",
fields: fields{
FS: &OSFileStore{},
- Config: &Config{
+ Config: Config{
FileRoot: func() string {
path, _ := os.Getwd()
return path + "/test/config/Files"
}()},
Logger: NewTestLogger(),
- Stats: &Stats{},
- fileTransfers: map[[4]byte]*FileTransfer{
- {0, 0, 0, 5}: {
- refNum: [4]byte{0, 0, 0, 5},
- Type: FileDownload,
- FileName: []byte("testfile-8b"),
- FilePath: []byte{},
- ClientConn: &ClientConn{
- Account: &Account{
- Login: "foo",
- },
- transfersMU: sync.Mutex{},
- transfers: map[int]map[[4]byte]*FileTransfer{
- FileDownload: {
- [4]byte{0, 0, 0, 5}: &FileTransfer{},
+ Stats: NewStats(),
+ FileTransferMgr: &MemFileTransferMgr{
+ fileTransfers: map[FileTransferID]*FileTransfer{
+ {0, 0, 0, 5}: {
+ refNum: [4]byte{0, 0, 0, 5},
+ Type: FileDownload,
+ FileName: []byte("testfile-8b"),
+ FilePath: []byte{},
+ ClientConn: &ClientConn{
+ Account: &Account{
+ Login: "foo",
+ },
+ ClientFileTransferMgr: ClientFileTransferMgr{
+ transfers: map[FileTransferType]map[FileTransferID]*FileTransfer{
+ FileDownload: {
+ [4]byte{0, 0, 0, 5}: &FileTransfer{},
+ },
+ },
},
},
+ bytesSentCounter: &WriteCounter{},
},
- bytesSentCounter: &WriteCounter{},
},
},
},
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &Server{
- Port: tt.fields.Port,
- Accounts: tt.fields.Accounts,
- Agreement: tt.fields.Agreement,
- Clients: tt.fields.Clients,
- ThreadedNews: tt.fields.ThreadedNews,
- fileTransfers: tt.fields.fileTransfers,
- Config: tt.fields.Config,
- ConfigDir: tt.fields.ConfigDir,
- Logger: tt.fields.Logger,
- Stats: tt.fields.Stats,
- FS: tt.fields.FS,
+ FileTransferMgr: tt.fields.FileTransferMgr,
+ Config: tt.fields.Config,
+ ConfigDir: tt.fields.ConfigDir,
+ Logger: tt.fields.Logger,
+ Stats: tt.fields.Stats,
+ FS: tt.fields.FS,
}
+
tt.wantErr(t, s.handleFileTransfer(tt.args.ctx, tt.args.rwc), fmt.Sprintf("handleFileTransfer(%v, %v)", tt.args.ctx, tt.args.rwc))
assertTransferBytesEqual(t, tt.wantDump, tt.args.rwc.(mockReadWriter).WBuf.Bytes())
"time"
)
+// Stat counter keys
+const (
+ StatCurrentlyConnected = iota
+ StatDownloadsInProgress
+ StatUploadsInProgress
+ StatWaitingDownloads
+ StatConnectionPeak
+ StatConnectionCounter
+ StatDownloadCounter
+ StatUploadCounter
+)
+
+type Counter interface {
+ Increment(keys ...int)
+ Decrement(key int)
+ Set(key, val int)
+ Get(key int) int
+ Values() map[string]interface{}
+}
+
type Stats struct {
- CurrentlyConnected int
- DownloadsInProgress int
- UploadsInProgress int
- WaitingDownloads int
- ConnectionPeak int
- ConnectionCounter int
- DownloadCounter int
- UploadCounter int
- Since time.Time
-
- sync.Mutex
+ stats map[int]int
+ since time.Time
+
+ mu sync.RWMutex
+}
+
+func NewStats() *Stats {
+ return &Stats{
+ since: time.Now(),
+ stats: map[int]int{
+ StatCurrentlyConnected: 0,
+ StatDownloadsInProgress: 0,
+ StatUploadsInProgress: 0,
+ StatWaitingDownloads: 0,
+ StatConnectionPeak: 0,
+ StatDownloadCounter: 0,
+ StatUploadCounter: 0,
+ StatConnectionCounter: 0,
+ },
+ }
+}
+
+func (s *Stats) Increment(keys ...int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ for _, key := range keys {
+ s.stats[key]++
+ }
+}
+
+func (s *Stats) Decrement(key int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.stats[key]--
+}
+
+func (s *Stats) Set(key, val int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ s.stats[key] = val
+}
+
+func (s *Stats) Get(key int) int {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ return s.stats[key]
+}
+
+func (s *Stats) Values() map[string]interface{} {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ return map[string]interface{}{
+ "CurrentlyConnected": s.stats[StatCurrentlyConnected],
+ "DownloadsInProgress": s.stats[StatDownloadsInProgress],
+ "UploadsInProgress": s.stats[StatUploadsInProgress],
+ "WaitingDownloads": s.stats[StatWaitingDownloads],
+ "ConnectionPeak": s.stats[StatConnectionPeak],
+ "ConnectionCounter": s.stats[StatConnectionCounter],
+ "DownloadCounter": s.stats[StatDownloadCounter],
+ "UploadCounter": s.stats[StatUploadCounter],
+ "Since": s.since,
+ }
}
func register(dialer Dialer, tracker string, tr io.Reader) error {
conn, err := dialer.Dial("udp", tracker)
if err != nil {
- return fmt.Errorf("failed to dial tracker: %w", err)
+ return fmt.Errorf("failed to dial tracker: %v", err)
}
defer conn.Close()
"errors"
"fmt"
"io"
- "log/slog"
"math/rand"
"slices"
)
+type TranType [2]byte
+
var (
- TranError = [2]byte{0x00, 0x00} // 0
- TranGetMsgs = [2]byte{0x00, 0x65} // 101
- TranNewMsg = [2]byte{0x00, 0x66} // 102
- TranOldPostNews = [2]byte{0x00, 0x67} // 103
- TranServerMsg = [2]byte{0x00, 0x68} // 104
- TranChatSend = [2]byte{0x00, 0x69} // 105
- TranChatMsg = [2]byte{0x00, 0x6A} // 106
- TranLogin = [2]byte{0x00, 0x6B} // 107
- TranSendInstantMsg = [2]byte{0x00, 0x6C} // 108
- TranShowAgreement = [2]byte{0x00, 0x6D} // 109
- TranDisconnectUser = [2]byte{0x00, 0x6E} // 110
- TranDisconnectMsg = [2]byte{0x00, 0x6F} // 111
- TranInviteNewChat = [2]byte{0x00, 0x70} // 112
- TranInviteToChat = [2]byte{0x00, 0x71} // 113
- TranRejectChatInvite = [2]byte{0x00, 0x72} // 114
- TranJoinChat = [2]byte{0x00, 0x73} // 115
- TranLeaveChat = [2]byte{0x00, 0x74} // 116
- TranNotifyChatChangeUser = [2]byte{0x00, 0x75} // 117
- TranNotifyChatDeleteUser = [2]byte{0x00, 0x76} // 118
- TranNotifyChatSubject = [2]byte{0x00, 0x77} // 119
- TranSetChatSubject = [2]byte{0x00, 0x78} // 120
- TranAgreed = [2]byte{0x00, 0x79} // 121
- TranServerBanner = [2]byte{0x00, 0x7A} // 122
- TranGetFileNameList = [2]byte{0x00, 0xC8} // 200
- TranDownloadFile = [2]byte{0x00, 0xCA} // 202
- TranUploadFile = [2]byte{0x00, 0xCB} // 203
- TranNewFolder = [2]byte{0x00, 0xCD} // 205
- TranDeleteFile = [2]byte{0x00, 0xCC} // 204
- TranGetFileInfo = [2]byte{0x00, 0xCE} // 206
- TranSetFileInfo = [2]byte{0x00, 0xCF} // 207
- TranMoveFile = [2]byte{0x00, 0xD0} // 208
- TranMakeFileAlias = [2]byte{0x00, 0xD1} // 209
- TranDownloadFldr = [2]byte{0x00, 0xD2} // 210
- TranDownloadInfo = [2]byte{0x00, 0xD3} // 211
- TranDownloadBanner = [2]byte{0x00, 0xD4} // 212
- TranUploadFldr = [2]byte{0x00, 0xD5} // 213
- TranGetUserNameList = [2]byte{0x01, 0x2C} // 300
- TranNotifyChangeUser = [2]byte{0x01, 0x2D} // 301
- TranNotifyDeleteUser = [2]byte{0x01, 0x2E} // 302
- TranGetClientInfoText = [2]byte{0x01, 0x2F} // 303
- TranSetClientUserInfo = [2]byte{0x01, 0x30} // 304
- TranListUsers = [2]byte{0x01, 0x5C} // 348
- TranUpdateUser = [2]byte{0x01, 0x5D} // 349
- TranNewUser = [2]byte{0x01, 0x5E} // 350
- TranDeleteUser = [2]byte{0x01, 0x5F} // 351
- TranGetUser = [2]byte{0x01, 0x60} // 352
- TranSetUser = [2]byte{0x01, 0x61} // 353
- TranUserAccess = [2]byte{0x01, 0x62} // 354
- TranUserBroadcast = [2]byte{0x01, 0x63} // 355
- TranGetNewsCatNameList = [2]byte{0x01, 0x72} // 370
- TranGetNewsArtNameList = [2]byte{0x01, 0x73} // 371
- TranDelNewsItem = [2]byte{0x01, 0x7C} // 380
- TranNewNewsFldr = [2]byte{0x01, 0x7D} // 381
- TranNewNewsCat = [2]byte{0x01, 0x7E} // 382
- TranGetNewsArtData = [2]byte{0x01, 0x90} // 400
- TranPostNewsArt = [2]byte{0x01, 0x9A} // 410
- TranDelNewsArt = [2]byte{0x01, 0x9B} // 411
- TranKeepAlive = [2]byte{0x01, 0xF4} // 500
+ TranError = TranType{0x00, 0x00} // 0
+ TranGetMsgs = TranType{0x00, 0x65} // 101
+ TranNewMsg = TranType{0x00, 0x66} // 102
+ TranOldPostNews = TranType{0x00, 0x67} // 103
+ TranServerMsg = TranType{0x00, 0x68} // 104
+ TranChatSend = TranType{0x00, 0x69} // 105
+ TranChatMsg = TranType{0x00, 0x6A} // 106
+ TranLogin = TranType{0x00, 0x6B} // 107
+ TranSendInstantMsg = TranType{0x00, 0x6C} // 108
+ TranShowAgreement = TranType{0x00, 0x6D} // 109
+ TranDisconnectUser = TranType{0x00, 0x6E} // 110
+ TranDisconnectMsg = TranType{0x00, 0x6F} // 111
+ TranInviteNewChat = TranType{0x00, 0x70} // 112
+ TranInviteToChat = TranType{0x00, 0x71} // 113
+ TranRejectChatInvite = TranType{0x00, 0x72} // 114
+ TranJoinChat = TranType{0x00, 0x73} // 115
+ TranLeaveChat = TranType{0x00, 0x74} // 116
+ TranNotifyChatChangeUser = TranType{0x00, 0x75} // 117
+ TranNotifyChatDeleteUser = TranType{0x00, 0x76} // 118
+ TranNotifyChatSubject = TranType{0x00, 0x77} // 119
+ TranSetChatSubject = TranType{0x00, 0x78} // 120
+ TranAgreed = TranType{0x00, 0x79} // 121
+ TranServerBanner = TranType{0x00, 0x7A} // 122
+ TranGetFileNameList = TranType{0x00, 0xC8} // 200
+ TranDownloadFile = TranType{0x00, 0xCA} // 202
+ TranUploadFile = TranType{0x00, 0xCB} // 203
+ TranNewFolder = TranType{0x00, 0xCD} // 205
+ TranDeleteFile = TranType{0x00, 0xCC} // 204
+ TranGetFileInfo = TranType{0x00, 0xCE} // 206
+ TranSetFileInfo = TranType{0x00, 0xCF} // 207
+ TranMoveFile = TranType{0x00, 0xD0} // 208
+ TranMakeFileAlias = TranType{0x00, 0xD1} // 209
+ TranDownloadFldr = TranType{0x00, 0xD2} // 210
+ TranDownloadInfo = TranType{0x00, 0xD3} // 211
+ TranDownloadBanner = TranType{0x00, 0xD4} // 212
+ TranUploadFldr = TranType{0x00, 0xD5} // 213
+ TranGetUserNameList = TranType{0x01, 0x2C} // 300
+ TranNotifyChangeUser = TranType{0x01, 0x2D} // 301
+ TranNotifyDeleteUser = TranType{0x01, 0x2E} // 302
+ TranGetClientInfoText = TranType{0x01, 0x2F} // 303
+ TranSetClientUserInfo = TranType{0x01, 0x30} // 304
+ TranListUsers = TranType{0x01, 0x5C} // 348
+ TranUpdateUser = TranType{0x01, 0x5D} // 349
+ TranNewUser = TranType{0x01, 0x5E} // 350
+ TranDeleteUser = TranType{0x01, 0x5F} // 351
+ TranGetUser = TranType{0x01, 0x60} // 352
+ TranSetUser = TranType{0x01, 0x61} // 353
+ TranUserAccess = TranType{0x01, 0x62} // 354
+ TranUserBroadcast = TranType{0x01, 0x63} // 355
+ TranGetNewsCatNameList = TranType{0x01, 0x72} // 370
+ TranGetNewsArtNameList = TranType{0x01, 0x73} // 371
+ TranDelNewsItem = TranType{0x01, 0x7C} // 380
+ TranNewNewsFldr = TranType{0x01, 0x7D} // 381
+ TranNewNewsCat = TranType{0x01, 0x7E} // 382
+ TranGetNewsArtData = TranType{0x01, 0x90} // 400
+ TranPostNewsArt = TranType{0x01, 0x9A} // 410
+ TranDelNewsArt = TranType{0x01, 0x9B} // 411
+ TranKeepAlive = TranType{0x01, 0xF4} // 500
)
type Transaction struct {
readOffset int // Internal offset to track read progress
}
-type TranType [2]byte
-
var tranTypeNames = map[TranType]string{
- TranChatMsg: "Receive Chat",
- TranNotifyChangeUser: "TranNotifyChangeUser",
- TranError: "TranError",
- TranShowAgreement: "TranShowAgreement",
- TranUserAccess: "TranUserAccess",
- TranNotifyDeleteUser: "TranNotifyDeleteUser",
+ TranChatMsg: "Receive chat",
+ TranNotifyChangeUser: "User change",
+ TranError: "Error",
+ TranShowAgreement: "Show Agreement",
+ TranUserAccess: "User access",
+ TranNotifyDeleteUser: "User left",
TranAgreed: "TranAgreed",
- TranChatSend: "Send Chat",
- TranDelNewsArt: "TranDelNewsArt",
- TranDelNewsItem: "TranDelNewsItem",
- TranDeleteFile: "TranDeleteFile",
- TranDeleteUser: "TranDeleteUser",
- TranDisconnectUser: "TranDisconnectUser",
- TranDownloadFile: "TranDownloadFile",
- TranDownloadFldr: "TranDownloadFldr",
- TranGetClientInfoText: "TranGetClientInfoText",
- TranGetFileInfo: "TranGetFileInfo",
- TranGetFileNameList: "TranGetFileNameList",
- TranGetMsgs: "TranGetMsgs",
- TranGetNewsArtData: "TranGetNewsArtData",
- TranGetNewsArtNameList: "TranGetNewsArtNameList",
- TranGetNewsCatNameList: "TranGetNewsCatNameList",
- TranGetUser: "TranGetUser",
- TranGetUserNameList: "tranHandleGetUserNameList",
- TranInviteNewChat: "TranInviteNewChat",
- TranInviteToChat: "TranInviteToChat",
- TranJoinChat: "TranJoinChat",
- TranKeepAlive: "TranKeepAlive",
- TranLeaveChat: "TranJoinChat",
- TranListUsers: "TranListUsers",
- TranMoveFile: "TranMoveFile",
- TranNewFolder: "TranNewFolder",
- TranNewNewsCat: "TranNewNewsCat",
- TranNewNewsFldr: "TranNewNewsFldr",
- TranNewUser: "TranNewUser",
- TranUpdateUser: "TranUpdateUser",
- TranOldPostNews: "TranOldPostNews",
- TranPostNewsArt: "TranPostNewsArt",
- TranRejectChatInvite: "TranRejectChatInvite",
- TranSendInstantMsg: "TranSendInstantMsg",
- TranSetChatSubject: "TranSetChatSubject",
- TranMakeFileAlias: "TranMakeFileAlias",
- TranSetClientUserInfo: "TranSetClientUserInfo",
- TranSetFileInfo: "TranSetFileInfo",
- TranSetUser: "TranSetUser",
- TranUploadFile: "TranUploadFile",
- TranUploadFldr: "TranUploadFldr",
- TranUserBroadcast: "TranUserBroadcast",
- TranDownloadBanner: "TranDownloadBanner",
+ TranChatSend: "Send chat",
+ TranDelNewsArt: "Delete news article",
+ TranDelNewsItem: "Delete news item",
+ TranDeleteFile: "Delete file",
+ TranDeleteUser: "Delete user",
+ TranDisconnectUser: "Disconnect user",
+ TranDownloadFile: "Download file",
+ TranDownloadFldr: "Download folder",
+ TranGetClientInfoText: "Get client info",
+ TranGetFileInfo: "Get file info",
+ TranGetFileNameList: "Get file list",
+ TranGetMsgs: "Get messages",
+ TranGetNewsArtData: "Get news article",
+ TranGetNewsArtNameList: "Get news article list",
+ TranGetNewsCatNameList: "Get news categories",
+ TranGetUser: "Get user",
+ TranGetUserNameList: "Get user list",
+ TranInviteNewChat: "Invite to new chat",
+ TranInviteToChat: "Invite to chat",
+ TranJoinChat: "Join chat",
+ TranKeepAlive: "Keepalive",
+ TranLeaveChat: "Leave chat",
+ TranListUsers: "List user accounts",
+ TranMoveFile: "Move file",
+ TranNewFolder: "Create folder",
+ TranNewNewsCat: "Create news category",
+ TranNewNewsFldr: "Create news bundle",
+ TranNewUser: "Create user account",
+ TranUpdateUser: "Update user account",
+ TranOldPostNews: "Post to message board",
+ TranPostNewsArt: "Create news article",
+ TranRejectChatInvite: "Decline chat invite",
+ TranSendInstantMsg: "Send message",
+ TranSetChatSubject: "Set chat subject",
+ TranMakeFileAlias: "Make file alias",
+ TranSetClientUserInfo: "Set client user info",
+ TranSetFileInfo: "Set file info",
+ TranSetUser: "Set user",
+ TranUploadFile: "Upload file",
+ TranUploadFldr: "Upload folder",
+ TranUserBroadcast: "Send broadcast",
+ TranDownloadBanner: "Download banner",
}
-func (t TranType) LogValue() slog.Value {
- return slog.StringValue(tranTypeNames[t])
-}
+//func (t TranType) LogValue() slog.Value {
+// return slog.StringValue(tranTypeNames[t])
+//}
-// NewTransaction creates a new Transaction with the specified type, client ID, and optional fields.
-func NewTransaction(t, clientID [2]byte, fields ...Field) Transaction {
+// NewTransaction creates a new Transaction with the specified type, client Type, and optional fields.
+func NewTransaction(t TranType, clientID [2]byte, fields ...Field) Transaction {
transaction := Transaction{
Type: t,
clientID: clientID,
const minFieldLen = 4
-func ReadFields(paramCount []byte, buf []byte) ([]Field, error) {
- paramCountInt := int(binary.BigEndian.Uint16(paramCount))
- if paramCountInt > 0 && len(buf) < minFieldLen {
- return []Field{}, fmt.Errorf("invalid field length %v", len(buf))
- }
-
- // A Field consists of:
- // ID: 2 bytes
- // Size: 2 bytes
- // Data: FieldSize number of bytes
- var fields []Field
- for i := 0; i < paramCountInt; i++ {
- if len(buf) < minFieldLen {
- return []Field{}, fmt.Errorf("invalid field length %v", len(buf))
- }
- fieldID := buf[0:2]
- fieldSize := buf[2:4]
- fieldSizeInt := int(binary.BigEndian.Uint16(buf[2:4]))
- expectedLen := minFieldLen + fieldSizeInt
- if len(buf) < expectedLen {
- return []Field{}, fmt.Errorf("field length too short")
- }
-
- fields = append(fields, Field{
- ID: [2]byte(fieldID),
- FieldSize: [2]byte(fieldSize),
- Data: buf[4 : 4+fieldSizeInt],
- })
-
- buf = buf[fieldSizeInt+4:]
- }
-
- if len(buf) != 0 {
- return []Field{}, fmt.Errorf("extra field bytes")
- }
-
- return fields, nil
-}
-
// Read implements the io.Reader interface for Transaction
func (t *Transaction) Read(p []byte) (int, error) {
payloadSize := t.Size()
return bs
}
-func (t *Transaction) GetField(id [2]byte) Field {
+func (t *Transaction) GetField(id [2]byte) *Field {
for _, field := range t.Fields {
- if id == field.ID {
- return field
+ if id == field.Type {
+ return &field
}
}
- return Field{}
+ return &Field{}
}
"bytes"
"encoding/binary"
"fmt"
- "github.com/davecgh/go-spew/spew"
- "gopkg.in/yaml.v3"
"io"
"math/big"
"os"
"path"
"path/filepath"
- "sort"
"strings"
"time"
)
const chatMsgLimit = 8192
func HandleChatSend(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(accessSendChat) {
+ if !cc.Authorize(AccessSendChat) {
return cc.NewErrReply(t, "You are not allowed to participate in 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) {
- privChat := cc.Server.PrivateChats[[4]byte(chatID)]
// send the message to all connected clients of the private chat
- for _, c := range privChat.ClientConn {
+ for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) {
res = append(res, NewTransaction(
TranChatMsg,
c.ID,
}
//cc.Server.mux.Lock()
- for _, c := range cc.Server.Clients {
+ 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) {
+ if c.Authorize(AccessReadChat) {
res = append(res, NewTransaction(TranChatMsg, c.ID, NewField(FieldData, []byte(formattedMsg))))
}
}
// HandleSendInstantMsg sends instant message to the user on the current server.
// Fields used in the request:
//
-// 103 User ID
+// 103 User Type
// 113 Options
// One of the following values:
// - User message (myOpt_UserMessage = 1)
// Fields used in the reply:
// None
func HandleSendInstantMsg(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(accessSendPrivMsg) {
+ if !cc.Authorize(AccessSendPrivMsg) {
return cc.NewErrReply(t, "You are not allowed to send private messages.")
}
reply.Fields = append(reply.Fields, NewField(FieldQuotingMsg, t.GetField(FieldQuotingMsg).Data))
}
- otherClient, ok := cc.Server.Clients[[2]byte(userID.Data)]
- if !ok {
+ otherClient := cc.Server.ClientMgr.Get([2]byte(userID.Data))
+ if otherClient == nil {
return res
}
if t.GetField(FieldFileComment).Data != nil {
switch mode := fi.Mode(); {
case mode.IsDir():
- if !cc.Authorize(accessSetFolderComment) {
+ if !cc.Authorize(AccessSetFolderComment) {
return cc.NewErrReply(t, "You are not allowed to set comments for folders.")
}
case mode.IsRegular():
- if !cc.Authorize(accessSetFileComment) {
+ if !cc.Authorize(AccessSetFileComment) {
return cc.NewErrReply(t, "You are not allowed to set comments for files.")
}
}
if fileNewName != nil {
switch mode := fi.Mode(); {
case mode.IsDir():
- if !cc.Authorize(accessRenameFolder) {
+ if !cc.Authorize(AccessRenameFolder) {
return cc.NewErrReply(t, "You are not allowed to rename folders.")
}
err = os.Rename(fullFilePath, fullNewFilePath)
}
case mode.IsRegular():
- if !cc.Authorize(accessRenameFile) {
+ if !cc.Authorize(AccessRenameFile) {
return cc.NewErrReply(t, "You are not allowed to rename files.")
}
fileDir, err := readPath(cc.Server.Config.FileRoot, filePath, []byte{})
switch mode := fi.Mode(); {
case mode.IsDir():
- if !cc.Authorize(accessDeleteFolder) {
+ if !cc.Authorize(AccessDeleteFolder) {
return cc.NewErrReply(t, "You are not allowed to delete folders.")
}
case mode.IsRegular():
- if !cc.Authorize(accessDeleteFile) {
+ if !cc.Authorize(AccessDeleteFile) {
return cc.NewErrReply(t, "You are not allowed to delete files.")
}
}
}
switch mode := fi.Mode(); {
case mode.IsDir():
- if !cc.Authorize(accessMoveFolder) {
+ if !cc.Authorize(AccessMoveFolder) {
return cc.NewErrReply(t, "You are not allowed to move folders.")
}
case mode.IsRegular():
- if !cc.Authorize(accessMoveFile) {
+ if !cc.Authorize(AccessMoveFile) {
return cc.NewErrReply(t, "You are not allowed to move files.")
}
}
}
func HandleNewFolder(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(accessCreateFolder) {
+ if !cc.Authorize(AccessCreateFolder) {
return cc.NewErrReply(t, "You are not allowed to create folders.")
}
folderName := string(t.GetField(FieldFileName).Data)
return cc.NewErrReply(t, msg)
}
- res = append(res, cc.NewReply(t))
- return res
+ return append(res, cc.NewReply(t))
}
func HandleSetUser(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(accessModifyUser) {
+ if !cc.Authorize(AccessModifyUser) {
return cc.NewErrReply(t, "You are not allowed to modify accounts.")
}
- login := string(encodeString(t.GetField(FieldUserLogin).Data))
+ login := t.GetField(FieldUserLogin).DecodeObfuscatedString()
userName := string(t.GetField(FieldUserName).Data)
newAccessLvl := t.GetField(FieldUserAccess).Data
- account := cc.Server.Accounts[login]
+ account := cc.Server.AccountManager.Get(login)
if account == nil {
return cc.NewErrReply(t, "Account not found.")
}
account.Password = hashAndSalt(t.GetField(FieldUserPassword).Data)
}
- out, err := yaml.Marshal(&account)
+ err := cc.Server.AccountManager.Update(*account, account.Login)
if err != nil {
- return res
- }
- if err := os.WriteFile(filepath.Join(cc.Server.ConfigDir, "Users", login+".yaml"), out, 0666); err != nil {
- return res
+ 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.Clients {
+ 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) {
+ if c.Authorize(AccessDisconUser) {
c.Flags.Set(UserFlagAdmin, 1)
} else {
c.Flags.Set(UserFlagAdmin, 0)
c.Account.Access = account.Access
- cc.sendAll(
+ cc.SendAll(
TranNotifyChangeUser,
NewField(FieldUserID, c.ID[:]),
NewField(FieldUserFlags, c.Flags[:]),
}
}
- res = append(res, cc.NewReply(t))
- return res
+ return append(res, cc.NewReply(t))
}
func HandleGetUser(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(accessOpenUser) {
+ if !cc.Authorize(AccessOpenUser) {
return cc.NewErrReply(t, "You are not allowed to view accounts.")
}
- account := cc.Server.Accounts[string(t.GetField(FieldUserLogin).Data)]
+ account := cc.Server.AccountManager.Get(string(t.GetField(FieldUserLogin).Data))
if account == nil {
return cc.NewErrReply(t, "Account does not exist.")
}
- res = append(res, cc.NewReply(t,
+ 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[:]),
))
- return res
}
func HandleListUsers(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(accessOpenUser) {
+ if !cc.Authorize(AccessOpenUser) {
return cc.NewErrReply(t, "You are not allowed to view accounts.")
}
var userFields []Field
- for _, acc := range cc.Server.Accounts {
- accCopy := *acc
- b, err := io.ReadAll(&accCopy)
+ for _, acc := range cc.Server.AccountManager.List() {
+ b, err := io.ReadAll(&acc)
if err != nil {
- return res
+ cc.logger.Error("Error reading account", "Account", acc.Login, "Err", err)
+ continue
}
userFields = append(userFields, NewField(FieldData, b))
}
- res = append(res, cc.NewReply(t, userFields...))
- return res
+ 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.
// 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) {
+ 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.DeleteUser(login); err != nil {
+ 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
}
}
// Check if accountToUpdate has an existing account. If so, we know we are updating an existing user.
- if acc, ok := cc.Server.Accounts[accountToUpdate]; ok {
+ 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) {
+ // Account exists, so this is an update action.
+ if !cc.Authorize(AccessModifyUser) {
return cc.NewErrReply(t, "You are not allowed to modify accounts.")
}
copy(acc.Access[:], getField(FieldUserAccess, &subFields).Data)
}
- err := cc.Server.UpdateUser(
- string(encodeString(getField(FieldData, &subFields).Data)),
- string(encodeString(getField(FieldUserLogin, &subFields).Data)),
- string(getField(FieldUserName, &subFields).Data),
- acc.Password,
- acc.Access,
- )
+ 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) {
+ if !cc.Authorize(AccessCreateUser) {
return cc.NewErrReply(t, "You are not allowed to create new accounts.")
}
}
}
- err := cc.Server.NewUser(userLogin, string(getField(FieldUserName, &subFields).Data), string(getField(FieldUserPassword, &subFields).Data), newAccess)
+ 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.")
}
// HandleNewUser creates a new user account
func HandleNewUser(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(accessCreateUser) {
+ if !cc.Authorize(AccessCreateUser) {
return cc.NewErrReply(t, "You are not allowed to create new accounts.")
}
- login := string(encodeString(t.GetField(FieldUserLogin).Data))
+ login := t.GetField(FieldUserLogin).DecodeObfuscatedString()
- // If the account already dataFile, reply with an error
- if _, ok := cc.Server.Accounts[login]; ok {
+ // 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.")
}
- newAccess := accessBitmap{}
+ var newAccess accessBitmap
copy(newAccess[:], t.GetField(FieldUserAccess).Data)
// Prevent account from creating new account with greater permission
}
}
- if err := cc.Server.NewUser(login, string(t.GetField(FieldUserName).Data), string(t.GetField(FieldUserPassword).Data), newAccess); err != nil {
+ 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.")
}
}
func HandleDeleteUser(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(accessDeleteUser) {
+ if !cc.Authorize(AccessDeleteUser) {
return cc.NewErrReply(t, "You are not allowed to delete accounts.")
}
- login := string(encodeString(t.GetField(FieldUserLogin).Data))
+ login := t.GetField(FieldUserLogin).DecodeObfuscatedString()
- if err := cc.Server.DeleteUser(login); err != nil {
+ 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) {
+ if !cc.Authorize(AccessBroadcast) {
return cc.NewErrReply(t, "You are not allowed to send broadcast messages.")
}
- cc.sendAll(
+ cc.SendAll(
TranServerMsg,
NewField(FieldData, t.GetField(FieldData).Data),
NewField(FieldChatOptions, []byte{0}),
// HandleGetClientInfoText returns user information for the specific user.
//
// Fields used in the request:
-// 103 User ID
+// 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) {
+ if !cc.Authorize(AccessGetClientInfo) {
return cc.NewErrReply(t, "You are not allowed to get client info.")
}
clientID := t.GetField(FieldUserID).Data
- clientConn := cc.Server.Clients[[2]byte(clientID)]
+ clientConn := cc.Server.ClientMgr.Get(ClientID(clientID))
if clientConn == nil {
return cc.NewErrReply(t, "User not found.")
}
- res = append(res, cc.NewReply(t,
+ return append(res, cc.NewReply(t,
NewField(FieldData, []byte(clientConn.String())),
NewField(FieldUserName, clientConn.UserName),
))
- return res
}
func HandleGetUserNameList(cc *ClientConn, t *Transaction) (res []Transaction) {
- return []Transaction{cc.NewReply(t, cc.Server.connectedUsers()...)}
+ 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) {
+ 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", "clientVersion", fmt.Sprintf("%v", func() int { i, _ := byteToInt(cc.Version); return i }()))
+ cc.logger.Info("Login successful")
options := t.GetField(FieldOptions).Data
optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options)))
cc.AutoReply = t.GetField(FieldAutomaticResponse).Data
}
- trans := cc.notifyOthers(
+ trans := cc.NotifyOthers(
NewTransaction(
TranNotifyChangeUser, [2]byte{0, 0},
NewField(FieldUserName, cc.UserName),
// Fields used in this request:
// 101 Data
func HandleTranOldPostNews(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(accessNewsPostArt) {
+ if !cc.Authorize(AccessNewsPostArt) {
return cc.NewErrReply(t, "You are not allowed to post news.")
}
- cc.Server.flatNewsMux.Lock()
- defer cc.Server.flatNewsMux.Unlock()
-
newsDateTemplate := defaultNewsDateFormat
if cc.Server.Config.NewsDateFormat != "" {
newsDateTemplate = cc.Server.Config.NewsDateFormat
newsPost := fmt.Sprintf(newsTemplate+"\r", cc.UserName, time.Now().Format(newsDateTemplate), t.GetField(FieldData).Data)
newsPost = strings.ReplaceAll(newsPost, "\n", "\r")
- // update news in memory
- cc.Server.FlatNews = append([]byte(newsPost), cc.Server.FlatNews...)
-
- // update news on disk
- if err := cc.Server.FS.WriteFile(filepath.Join(cc.Server.ConfigDir, "MessageBoard.txt"), cc.Server.FlatNews, 0644); err != nil {
- return res
+ _, 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(
+ cc.SendAll(
TranNewMsg,
NewField(FieldData, []byte(newsPost)),
)
- res = append(res, cc.NewReply(t))
- return res
+ return append(res, cc.NewReply(t))
}
func HandleDisconnectUser(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(accessDisconUser) {
+ if !cc.Authorize(AccessDisconUser) {
return cc.NewErrReply(t, "You are not allowed to disconnect users.")
}
- clientConn := cc.Server.Clients[[2]byte(t.GetField(FieldUserID).Data)]
+ clientID := [2]byte(t.GetField(FieldUserID).Data)
+ clientConn := cc.Server.ClientMgr.Get(clientID)
- if clientConn.Authorize(accessCannotBeDiscon) {
+ if clientConn.Authorize(AccessCannotBeDiscon) {
return cc.NewErrReply(t, clientConn.Account.Login+" is not allowed to be disconnected.")
}
))
banUntil := time.Now().Add(tempBanDuration)
- cc.Server.banList[strings.Split(clientConn.RemoteAddr, ":")[0]] = &banUntil
+ 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))
NewField(FieldChatOptions, []byte{0, 0}),
))
- cc.Server.banList[strings.Split(clientConn.RemoteAddr, ":")[0]] = nil
- }
+ ip := strings.Split(clientConn.RemoteAddr, ":")[0]
- err := cc.Server.writeBanList()
- if err != nil {
- return res
+ err := cc.Server.BanList.Add(ip, nil)
+ if err != nil {
+ // TODO
+ }
}
}
// Fields used in the request:
// 325 News path (Optional)
func HandleGetNewsCatNameList(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(accessNewsReadArt) {
+ if !cc.Authorize(AccessNewsReadArt) {
return cc.NewErrReply(t, "You are not allowed to read news.")
}
- pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
- cats := cc.Server.GetNewsCatByPath(pathStrs)
+ pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
+ if err != nil {
- // To store the keys in slice in sorted order
- keys := make([]string, len(cats))
- i := 0
- for k := range cats {
- keys[i] = k
- i++
}
- sort.Strings(keys)
-
- var fieldData []Field
- for _, k := range keys {
- cat := cats[k]
- b, _ := io.ReadAll(&cat)
+ var fields []Field
+ for _, cat := range cc.Server.ThreadedNewsMgr.GetCategories(pathStrs) {
+ b, err := io.ReadAll(&cat)
+ if err != nil {
+ // TODO
+ }
- fieldData = append(fieldData, NewField(FieldNewsCatListData15, b))
+ fields = append(fields, NewField(FieldNewsCatListData15, b))
}
- res = append(res, cc.NewReply(t, fieldData...))
- return res
+ return append(res, cc.NewReply(t, fields...))
}
func HandleNewNewsCat(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(accessNewsCreateCat) {
+ if !cc.Authorize(AccessNewsCreateCat) {
return cc.NewErrReply(t, "You are not allowed to create news categories.")
}
name := string(t.GetField(FieldNewsCatName).Data)
- pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
-
- cats := cc.Server.GetNewsCatByPath(pathStrs)
- cats[name] = NewsCategoryListData15{
- Name: name,
- Type: [2]byte{0, 3},
- Articles: map[uint32]*NewsArtData{},
- SubCats: make(map[string]NewsCategoryListData15),
+ pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
+ if err != nil {
+ return res
}
- if err := cc.Server.writeThreadedNews(); err != nil {
- return res
+ err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, NewsCategory)
+ if err != nil {
+ cc.logger.Error("error creating news category", "err", err)
}
- res = append(res, cc.NewReply(t))
- return res
+
+ 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) {
+ if !cc.Authorize(AccessNewsCreateFldr) {
return cc.NewErrReply(t, "You are not allowed to create news folders.")
}
name := string(t.GetField(FieldFileName).Data)
- pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
-
- cats := cc.Server.GetNewsCatByPath(pathStrs)
- cats[name] = NewsCategoryListData15{
- Name: name,
- Type: [2]byte{0, 2},
- Articles: map[uint32]*NewsArtData{},
- SubCats: make(map[string]NewsCategoryListData15),
- }
- if err := cc.Server.writeThreadedNews(); err != nil {
+ pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
+ if err != nil {
return res
}
- res = append(res, cc.NewReply(t))
- 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 reply:
// 321 News article list data Optional
func HandleGetNewsArtNameList(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(accessNewsReadArt) {
+ if !cc.Authorize(AccessNewsReadArt) {
return cc.NewErrReply(t, "You are not allowed to read news.")
}
- pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
-
- var cat NewsCategoryListData15
- cats := cc.Server.ThreadedNews.Categories
-
- for _, fp := range pathStrs {
- cat = cats[fp]
- cats = cats[fp].SubCats
+ pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
+ if err != nil {
+ return res
}
- nald := cat.GetNewsArtListData()
+ nald := cc.Server.ThreadedNewsMgr.ListArticles(pathStrs)
b, err := io.ReadAll(&nald)
if err != nil {
return res
}
- res = append(res, cc.NewReply(t, NewField(FieldNewsArtListData, b)))
- return res
+ return append(res, cc.NewReply(t, NewField(FieldNewsArtListData, b)))
}
// HandleGetNewsArtData requests information about the specific news article.
//
// Request fields
// 325 News path
-// 326 News article ID
+// 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 ID
-// 332 Next article ID
-// 335 Parent article ID
-// 336 First child article ID
+// 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) {
+ if !cc.Authorize(AccessNewsReadArt) {
return cc.NewErrReply(t, "You are not allowed to read news.")
}
- var cat NewsCategoryListData15
- cats := cc.Server.ThreadedNews.Categories
-
- for _, fp := range ReadNewsPath(t.GetField(FieldNewsPath).Data) {
- cat = cats[fp]
- cats = cats[fp].SubCats
+ newsPath, err := t.GetField(FieldNewsPath).DecodeNewsPath()
+ if err != nil {
+ return res
}
- // The official Hotline clients will send the article ID as 2 bytes if possible, but
- // some third party clients such as Frogblast and Heildrun will always send 4 bytes
- convertedID, err := byteToInt(t.GetField(FieldNewsArtID).Data)
+ convertedID, err := t.GetField(FieldNewsArtID).DecodeInt()
if err != nil {
return res
}
- art := cat.Articles[uint32(convertedID)]
+ art := cc.Server.ThreadedNewsMgr.GetArticle(newsPath, uint32(convertedID))
if art == nil {
return append(res, cc.NewReply(t))
}
return res
}
-// HandleDelNewsItem deletes an existing threaded news folder or category from the server.
+// 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 := ReadNewsPath(t.GetField(FieldNewsPath).Data)
-
- cats := cc.Server.ThreadedNews.Categories
- delName := pathStrs[len(pathStrs)-1]
- if len(pathStrs) > 1 {
- for _, fp := range pathStrs[0 : len(pathStrs)-1] {
- cats = cats[fp].SubCats
- }
+ pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
+ if err != nil {
+ return res
}
- if cats[delName].Type == [2]byte{0, 3} {
- if !cc.Authorize(accessNewsDeleteCat) {
+ 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) {
+ if !cc.Authorize(AccessNewsDeleteFldr) {
return cc.NewErrReply(t, "You are not allowed to delete news folders.")
}
}
- delete(cats, delName)
-
- if err := cc.Server.writeThreadedNews(); err != nil {
+ 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) {
+ if !cc.Authorize(AccessNewsDeleteArt) {
return cc.NewErrReply(t, "You are not allowed to delete news articles.")
}
- // Request Fields
- // 325 News path
- // 326 News article ID
- // 337 News article – recursive delete Delete child articles (1) or not (0)
- pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
- ID, err := byteToInt(t.GetField(FieldNewsArtID).Data)
+ pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
if err != nil {
return res
}
- // TODO: Delete recursive
- cats := cc.Server.GetNewsCatByPath(pathStrs[:len(pathStrs)-1])
-
- catName := pathStrs[len(pathStrs)-1]
- cat := cats[catName]
+ articleID, err := t.GetField(FieldNewsArtID).DecodeInt()
+ if err != nil {
+ cc.logger.Error("error reading article Type", "err", err)
+ return
+ }
- delete(cat.Articles, uint32(ID))
+ deleteRecursive := bytes.Equal([]byte{0, 1}, t.GetField(FieldNewsArtRecurseDel).Data)
- cats[catName] = cat
- if err := cc.Server.writeThreadedNews(); err != nil {
- return res
+ err = cc.Server.ThreadedNewsMgr.DeleteArticle(pathStrs, uint32(articleID), deleteRecursive)
+ if err != nil {
+ cc.logger.Error("error deleting news article", "err", err)
}
- res = append(res, cc.NewReply(t))
- return res
+ return []Transaction{cc.NewReply(t)}
}
// Request fields
// 325 News path
-// 326 News article ID ID of the parent article?
+// 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) {
+ if !cc.Authorize(AccessNewsPostArt) {
return cc.NewErrReply(t, "You are not allowed to post news articles.")
}
- pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
- cats := cc.Server.GetNewsCatByPath(pathStrs[:len(pathStrs)-1])
-
- catName := pathStrs[len(pathStrs)-1]
- cat := cats[catName]
-
- artID, err := byteToInt(t.GetField(FieldNewsArtID).Data)
+ pathStrs, err := t.GetField(FieldNewsPath).DecodeNewsPath()
if err != nil {
return res
}
- convertedArtID := uint32(artID)
- bs := make([]byte, 4)
- binary.BigEndian.PutUint32(bs, convertedArtID)
-
- cc.Server.mux.Lock()
- defer cc.Server.mux.Unlock()
-
- newArt := NewsArtData{
- Title: string(t.GetField(FieldNewsArtTitle).Data),
- Poster: string(cc.UserName),
- Date: toHotlineTime(time.Now()),
- ParentArt: [4]byte(bs),
- DataFlav: []byte("text/plain"),
- Data: string(t.GetField(FieldNewsArtData).Data),
- }
-
- var keys []int
- for k := range cat.Articles {
- keys = append(keys, int(k))
- }
-
- nextID := uint32(1)
- if len(keys) > 0 {
- sort.Ints(keys)
- prevID := uint32(keys[len(keys)-1])
- nextID = prevID + 1
-
- binary.BigEndian.PutUint32(newArt.PrevArt[:], prevID)
-
- // Set next article ID
- binary.BigEndian.PutUint32(cat.Articles[prevID].NextArt[:], nextID)
- }
-
- // Update parent article with first child reply
- parentID := convertedArtID
- if parentID != 0 {
- parentArt := cat.Articles[parentID]
- if parentArt.FirstChildArt == [4]byte{0, 0, 0, 0} {
- binary.BigEndian.PutUint32(parentArt.FirstChildArt[:], nextID)
- }
+ parentArticleID, err := t.GetField(FieldNewsArtID).DecodeInt()
+ if err != nil {
+ return res
}
- cat.Articles[nextID] = &newArt
-
- cats[catName] = cat
- if err := cc.Server.writeThreadedNews(); 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) {
+ if !cc.Authorize(AccessNewsReadArt) {
return cc.NewErrReply(t, "You are not allowed to read news.")
}
- res = append(res, cc.NewReply(t, NewField(FieldData, cc.Server.FlatNews)))
+ _, _ = cc.Server.MessageBoard.Seek(0, 0)
- return res
+ 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) {
+ if !cc.Authorize(AccessDownloadFile) {
return cc.NewErrReply(t, "You are not allowed to download files.")
}
ft.fileResumeData = &frd
}
- // Optional field for when a HL v1.5+ client requests file preview
+ // 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 {
// Download all files from the specified folder and sub-folders
func HandleDownloadFolder(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(accessDownloadFile) {
+ if !cc.Authorize(AccessDownloadFile) {
return cc.NewErrReply(t, "You are not allowed to download folders.")
}
if err != nil {
return res
}
- spew.Dump(itemCount)
fileTransfer := cc.newFileTransfer(FolderDownload, t.GetField(FieldFileName).Data, t.GetField(FieldFilePath).Data, transferSize)
}
// Handle special cases for Upload and Drop Box folders
- if !cc.Authorize(accessUploadAnywhere) {
+ 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)))
}
// 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) {
+ if !cc.Authorize(AccessUploadFile) {
return cc.NewErrReply(t, "You are not allowed to upload files.")
}
}
// Handle special cases for Upload and Drop Box folders
- if !cc.Authorize(accessUploadAnywhere) {
+ 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)))
}
} else {
cc.Icon = t.GetField(FieldUserIconID).Data
}
- if cc.Authorize(accessAnyName) {
+ if cc.Authorize(AccessAnyName) {
cc.UserName = t.GetField(FieldUserName).Data
}
- cc.flagsMU.Lock()
- defer cc.flagsMU.Unlock()
-
// 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()))
+ //flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(cc.Flags[:])))
+ //flagBitmap.SetBit(flagBitmap, UserFlagRefusePM, optBitmap.Bit(UserOptRefusePM))
+ //binary.BigEndian.PutUint16(cc.Flags[:], uint16(flagBitmap.Int64()))
- flagBitmap.SetBit(flagBitmap, UserFlagRefusePChat, optBitmap.Bit(UserOptRefuseChat))
- 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 {
}
}
- for _, c := range cc.Server.Clients {
+ for _, c := range cc.Server.ClientMgr.List() {
res = append(res, NewTransaction(
TranNotifyChangeUser,
c.ID,
}
// Handle special case for drop box folders
- if fp.IsDropbox() && !cc.Authorize(accessViewDropBoxes) {
+ if fp.IsDropbox() && !cc.Authorize(AccessViewDropBoxes) {
return cc.NewErrReply(t, "You are not allowed to view drop boxes.")
}
// =================================
// Hotline private chat flow
// =================================
-// 1. ClientA sends TranInviteNewChat to server with user ID to invite
+// 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 ID
+// 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:
// HandleInviteNewChat invites users to new private chat
func HandleInviteNewChat(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(accessOpenChat) {
+ if !cc.Authorize(AccessOpenChat) {
return cc.NewErrReply(t, "You are not allowed to request private chat.")
}
// Client to Invite
targetID := t.GetField(FieldUserID).Data
- newChatID := cc.Server.NewPrivateChat(cc)
+
+ // 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.Clients[[2]byte(targetID)]
+ 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,
)
}
- res = append(res,
+ return append(
+ res,
cc.NewReply(t,
NewField(FieldChatID, newChatID[:]),
NewField(FieldUserName, cc.UserName),
NewField(FieldUserFlags, cc.Flags[:]),
),
)
-
- return res
}
func HandleInviteToChat(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(accessOpenChat) {
+ if !cc.Authorize(AccessOpenChat) {
return cc.NewErrReply(t, "You are not allowed to request private chat.")
}
func HandleRejectChatInvite(cc *ClientConn, t *Transaction) (res []Transaction) {
chatID := [4]byte(t.GetField(FieldChatID).Data)
- privChat := cc.Server.PrivateChats[chatID]
- for _, c := range privChat.ClientConn {
+ for _, c := range cc.Server.ChatMgr.Members(chatID) {
res = append(res,
NewTransaction(
TranChatMsg,
func HandleJoinChat(cc *ClientConn, t *Transaction) (res []Transaction) {
chatID := t.GetField(FieldChatID).Data
- privChat := cc.Server.PrivateChats[[4]byte(chatID)]
-
// Send TranNotifyChatChangeUser to current members of the chat to inform of new user
- for _, c := range privChat.ClientConn {
+ for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) {
res = append(res,
NewTransaction(
TranNotifyChatChangeUser,
)
}
- privChat.ClientConn[cc.ID] = cc
+ cc.Server.ChatMgr.Join(ChatID(chatID), cc)
+
+ subject := cc.Server.ChatMgr.GetSubject(ChatID(chatID))
- replyFields := []Field{NewField(FieldChatSubject, []byte(privChat.Subject))}
- for _, c := range privChat.ClientConn {
+ 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,
replyFields = append(replyFields, NewField(FieldUsernameWithInfo, b))
}
- res = append(res, cc.NewReply(t, replyFields...))
- return res
+ return append(res, cc.NewReply(t, replyFields...))
}
// HandleLeaveChat is sent from a v1.8+ Hotline client when the user exits a private chat
func HandleLeaveChat(cc *ClientConn, t *Transaction) (res []Transaction) {
chatID := t.GetField(FieldChatID).Data
- privChat, ok := cc.Server.PrivateChats[[4]byte(chatID)]
- if !ok {
- return res
- }
-
- delete(privChat.ClientConn, cc.ID)
+ cc.Server.ChatMgr.Leave([4]byte(chatID), cc.ID)
// Notify members of the private chat that the user has left
- for _, c := range privChat.ClientConn {
+ for _, c := range cc.Server.ChatMgr.Members(ChatID(chatID)) {
res = append(res,
NewTransaction(
TranNotifyChatDeleteUser,
// HandleSetChatSubject is sent from a v1.8+ Hotline client when the user sets a private chat subject
// Fields used in the request:
-// * 114 Chat ID
+// * 114 Chat Type
// * 115 Chat subject
// Reply is not expected.
func HandleSetChatSubject(cc *ClientConn, t *Transaction) (res []Transaction) {
chatID := t.GetField(FieldChatID).Data
- privChat := cc.Server.PrivateChats[[4]byte(chatID)]
- privChat.Subject = string(t.GetField(FieldChatSubject).Data)
+ cc.Server.ChatMgr.SetSubject([4]byte(chatID), string(t.GetField(FieldChatSubject).Data))
- for _, c := range privChat.ClientConn {
+ // Notify chat members of new subject.
+ for _, c := range cc.Server.ChatMgr.Members([4]byte(chatID)) {
res = append(res,
NewTransaction(
TranNotifyChatSubject,
// Fields used in the reply:
// None
func HandleMakeAlias(cc *ClientConn, t *Transaction) (res []Transaction) {
- if !cc.Authorize(accessMakeAlias) {
+ if !cc.Authorize(AccessMakeAlias) {
return cc.NewErrReply(t, "You are not allowed to make aliases.")
}
fileName := t.GetField(FieldFileName).Data
// 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))
+ 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,
"os"
"path/filepath"
"strings"
- "sync"
"testing"
"time"
)
cc: &ClientConn{
UserName: []byte{0x00, 0x01},
Server: &Server{
- 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},
+ 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},
},
- [2]byte{0, 2}: {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- },
- ID: [2]byte{0, 2},
+ ID: [2]byte{0, 1},
+ },
+ {
+ Account: &Account{
+ Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
},
+ ID: [2]byte{0, 2},
},
- },
- },
- Clients: map[[2]byte]*ClientConn{
- [2]byte{0, 1}: {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ })
+ 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},
},
- ID: [2]byte{0, 1},
- },
- [2]byte{0, 2}: {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ {
+ Account: &Account{
+ Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 2},
},
- ID: [2]byte{0, 2},
},
- },
+ )
+ return &m
+ }(),
},
},
t: Transaction{
want []Transaction
}{
{
- name: "returns expected transactions",
+ name: "when client 2 leaves chat",
args: args{
cc: &ClientConn{
ID: [2]byte{0, 2},
Server: &Server{
- PrivateChats: map[[4]byte]*PrivateChat{
- [4]byte{0, 0, 0, 1}: {
- 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},
+ 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},
},
- },
- },
- Clients: map[[2]byte]*ClientConn{
- [2]byte{0, 1}: {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ })
+ 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},
},
- ID: [2]byte{0, 1},
- },
- [2]byte{0, 2}: {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ {
+ Account: &Account{
+ Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 2},
},
- ID: [2]byte{0, 2},
},
- },
+ )
+ return &m
+ }(),
},
},
t: NewTransaction(TranDeleteUser, [2]byte{}, NewField(FieldChatID, []byte{0, 0, 0, 1})),
cc: &ClientConn{
ID: [2]byte{0, 1},
Server: &Server{
- Clients: map[[2]byte]*ClientConn{
- [2]byte{0, 1}: {
- ID: [2]byte{0, 1},
- Icon: []byte{0, 2},
- Flags: [2]byte{0, 3},
- UserName: []byte{0, 4},
- },
- [2]byte{0, 2}: {
- ID: [2]byte{0, 2},
- Icon: []byte{0, 2},
- Flags: [2]byte{0, 3},
- UserName: []byte{0, 4},
+ 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{},
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessSendChat)
+ bits.Set(AccessSendChat)
return bits
}(),
},
UserName: []byte{0x00, 0x01},
Server: &Server{
- Clients: map[[2]byte]*ClientConn{
- [2]byte{0, 1}: {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ 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},
},
- ID: [2]byte{0, 1},
- },
- [2]byte{0, 2}: {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ {
+ Account: &Account{
+ Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 2},
},
- ID: [2]byte{0, 2},
},
- },
+ )
+ return &m
+ }(),
},
},
t: Transaction{
},
},
{
- name: "treats Chat ID 00 00 00 00 as a public chat message",
+ 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)
+ bits.Set(AccessSendChat)
return bits
}(),
},
UserName: []byte{0x00, 0x01},
Server: &Server{
- Clients: map[[2]byte]*ClientConn{
- [2]byte{0, 1}: {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ 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},
},
- ID: [2]byte{0, 1},
- },
- [2]byte{0, 2}: {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ {
+ Account: &Account{
+ Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 2},
},
- ID: [2]byte{0, 2},
},
- },
+ )
+ return &m
+ }(),
},
},
t: Transaction{
}(),
},
Server: &Server{
- Accounts: map[string]*Account{},
+ //Accounts: map[string]*Account{},
},
},
t: NewTransaction(
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessSendChat)
+ bits.Set(AccessSendChat)
return bits
}(),
},
UserName: []byte("Testy McTest"),
Server: &Server{
- Clients: map[[2]byte]*ClientConn{
- [2]byte{0, 1}: {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ 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},
},
- ID: [2]byte{0, 1},
- },
- [2]byte{0, 2}: {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ {
+ Account: &Account{
+ Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 2},
},
- ID: [2]byte{0, 2},
},
- },
+ )
+ return &m
+ }(),
},
},
t: Transaction{
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessSendChat)
+ bits.Set(AccessSendChat)
return bits
}(),
},
UserName: []byte("Testy McTest"),
Server: &Server{
- Clients: map[[2]byte]*ClientConn{
- [2]byte{0, 1}: {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ 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},
},
- ID: [2]byte{0, 1},
- },
- [2]byte{0, 2}: {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ {
+ Account: &Account{
+ Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ },
+ ID: [2]byte{0, 2},
},
- ID: [2]byte{0, 2},
},
- },
+ )
+ return &m
+ }(),
},
},
t: Transaction{
},
},
{
- name: "only sends chat msg to clients with accessReadChat permission",
+ 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)
+ bits.Set(AccessSendChat)
return bits
}(),
},
UserName: []byte{0x00, 0x01},
Server: &Server{
- Clients: map[[2]byte]*ClientConn{
- [2]byte{0, 1}: {
- Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- bits.Set(accessReadChat)
- return bits
- }()},
- ID: [2]byte{0, 1},
- },
- [2]byte{0, 2}: {
- Account: &Account{
- Access: accessBitmap{0, 0, 0, 0, 0, 0, 0, 0},
+ 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},
},
- ID: [2]byte{0, 2},
},
- },
+ )
+ return &m
+ }(),
},
},
t: Transaction{
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessSendChat)
+ bits.Set(AccessSendChat)
return bits
}(),
},
UserName: []byte{0x00, 0x01},
Server: &Server{
- PrivateChats: map[[4]byte]*PrivateChat{
- [4]byte{0, 0, 0, 1}: {
- ClientConn: map[[2]byte]*ClientConn{
- [2]byte{0, 1}: {
- ID: [2]byte{0, 1},
- },
- [2]byte{0, 2}: {
- ID: [2]byte{0, 2},
- },
+ ChatMgr: func() *MockChatManager {
+ m := MockChatManager{}
+ m.On("Members", ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*ClientConn{
+ {
+ ID: [2]byte{0, 1},
},
- },
- },
- Clients: map[[2]byte]*ClientConn{
- [2]byte{0, 1}: {
- Account: &Account{
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
+ {
+ ID: [2]byte{0, 2},
},
- ID: [2]byte{0, 1},
- },
- [2]byte{0, 2}: {
- Account: &Account{
- Access: accessBitmap{0, 0, 0, 0, 0, 0, 0, 0},
+ })
+ 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},
},
- ID: [2]byte{0, 2},
- },
- [2]byte{0, 3}: {
- Account: &Account{
- Access: accessBitmap{0, 0, 0, 0, 0, 0, 0, 0},
+ {
+ 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},
},
- ID: [2]byte{0, 3},
},
- },
+ )
+ return &m
+ }(),
},
},
t: Transaction{
ID: [2]byte{0x00, 0x01},
Server: &Server{
FS: &OSFileStore{},
- Config: &Config{
+ Config: Config{
FileRoot: func() string {
path, _ := os.Getwd()
return filepath.Join(path, "/test/config/Files")
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessCreateFolder)
+ bits.Set(AccessCreateFolder)
return bits
}(),
},
ID: [2]byte{0, 1},
Server: &Server{
- Config: &Config{
+ Config: Config{
FileRoot: "/Files/",
},
FS: func() *MockFileStore {
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessCreateFolder)
+ bits.Set(AccessCreateFolder)
return bits
}(),
},
ID: [2]byte{0, 1},
Server: &Server{
- Config: &Config{
+ Config: Config{
FileRoot: "/Files",
},
FS: func() *MockFileStore {
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessCreateFolder)
+ bits.Set(AccessCreateFolder)
return bits
}(),
},
ID: [2]byte{0, 1},
Server: &Server{
- Config: &Config{
+ Config: Config{
FileRoot: "/Files/",
},
FS: func() *MockFileStore {
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessCreateFolder)
+ bits.Set(AccessCreateFolder)
return bits
}(),
},
ID: [2]byte{0, 1},
Server: &Server{
- Config: &Config{
+ Config: Config{
FileRoot: "/Files/",
},
FS: func() *MockFileStore {
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessCreateFolder)
+ bits.Set(AccessCreateFolder)
return bits
}(),
},
ID: [2]byte{0, 1},
Server: &Server{
- Config: &Config{
+ Config: Config{
FileRoot: "/Files/",
},
FS: func() *MockFileStore {
args: args{
cc: &ClientConn{
Server: &Server{
- FS: &OSFileStore{},
- fileTransfers: map[[4]byte]*FileTransfer{},
- Config: &Config{
+ FS: &OSFileStore{},
+ FileTransferMgr: NewMemFileTransferMgr(),
+ Config: Config{
FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(),
}},
- transfers: map[int]map[[4]byte]*FileTransfer{
- FileUpload: {},
- },
+ ClientFileTransferMgr: NewClientFileTransferMgr(),
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessUploadFile)
- bits.Set(accessUploadAnywhere)
+ bits.Set(AccessUploadFile)
+ bits.Set(AccessUploadAnywhere)
return bits
}(),
},
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessMakeAlias)
+ bits.Set(AccessMakeAlias)
return bits
}(),
},
Server: &Server{
- Config: &Config{
+ Config: Config{
FileRoot: func() string {
path, _ := os.Getwd()
return path + "/test/config/Files"
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessMakeAlias)
+ bits.Set(AccessMakeAlias)
return bits
}(),
},
Server: &Server{
- Config: &Config{
+ Config: Config{
FileRoot: func() string {
path, _ := os.Getwd()
return path + "/test/config/Files"
cc: &ClientConn{
logger: NewTestLogger(),
Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
+ Access: accessBitmap{},
},
Server: &Server{
- Config: &Config{
+ Config: Config{
FileRoot: func() string {
path, _ := os.Getwd()
return path + "/test/config/Files"
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessOpenUser)
+ bits.Set(AccessOpenUser)
return bits
}(),
},
Server: &Server{
- Accounts: map[string]*Account{
- "guest": {
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Get", "guest").Return(&Account{
Login: "guest",
Name: "Guest",
Password: "password",
Access: accessBitmap{},
- },
- },
+ })
+ return &m
+ }(),
},
},
t: NewTransaction(
}(),
},
Server: &Server{
- Accounts: map[string]*Account{},
+ //Accounts: map[string]*Account{},
},
},
t: NewTransaction(
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessOpenUser)
+ bits.Set(AccessOpenUser)
return bits
}(),
},
Server: &Server{
- Accounts: map[string]*Account{},
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Get", "nonExistentUser").Return((*Account)(nil))
+ return &m
+ }(),
},
},
t: NewTransaction(
wantRes []Transaction
}{
{
- name: "when user dataFile",
+ name: "when user exists",
args: args{
cc: &ClientConn{
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessDeleteUser)
+ bits.Set(AccessDeleteUser)
return bits
}(),
},
Server: &Server{
- Accounts: map[string]*Account{
- "testuser": {
- Login: "testuser",
- Name: "Testy McTest",
- Password: "password",
- Access: accessBitmap{},
- },
- },
- FS: func() *MockFileStore {
- mfs := &MockFileStore{}
- mfs.On("Remove", "Users/testuser.yaml").Return(nil)
- return mfs
+ 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
}(),
},
},
args: args{
cc: &ClientConn{
Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
+ Access: accessBitmap{},
},
Server: &Server{
- Accounts: map[string]*Account{},
+ //Accounts: map[string]*Account{},
},
},
t: NewTransaction(
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessNewsReadArt)
+ bits.Set(AccessNewsReadArt)
return bits
}(),
},
Server: &Server{
- FlatNews: []byte("TEST"),
+ 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(
args: args{
cc: &ClientConn{
Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
+ Access: accessBitmap{},
},
Server: &Server{
- Accounts: map[string]*Account{},
+ //Accounts: map[string]*Account{},
},
},
t: NewTransaction(
}(),
},
Server: &Server{
- Accounts: map[string]*Account{},
+ //Accounts: map[string]*Account{},
},
},
t: NewTransaction(
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessCreateUser)
+ bits.Set(AccessCreateUser)
return bits
}(),
},
Server: &Server{
- Accounts: map[string]*Account{},
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Get", "userB").Return((*Account)(nil))
+ return &m
+ }(),
},
},
t: NewTransaction(
TranNewUser, [2]byte{0, 1},
- NewField(FieldUserLogin, []byte("userB")),
+ NewField(FieldUserLogin, encodeString([]byte("userB"))),
NewField(
FieldUserAccess,
func() []byte {
var bits accessBitmap
- bits.Set(accessDisconUser)
+ bits.Set(AccessDisconUser)
return bits[:]
}(),
),
}(),
},
Server: &Server{
- Accounts: map[string]*Account{},
+ //Accounts: map[string]*Account{},
},
},
t: NewTransaction(
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessOpenUser)
+ bits.Set(AccessOpenUser)
return bits
}(),
},
Server: &Server{
- Accounts: map[string]*Account{
- "guest": {
- Name: "guest",
- Login: "guest",
- Password: "zz",
- Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- },
- },
+ 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(
name: "with a valid file",
args: args{
cc: &ClientConn{
- transfers: map[int]map[[4]byte]*FileTransfer{
- FileDownload: {},
- },
+ ClientFileTransferMgr: NewClientFileTransferMgr(),
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessDownloadFile)
+ bits.Set(AccessDownloadFile)
return bits
}(),
},
Server: &Server{
- FS: &OSFileStore{},
- fileTransfers: map[[4]byte]*FileTransfer{},
- Config: &Config{
+ FS: &OSFileStore{},
+ FileTransferMgr: NewMemFileTransferMgr(),
+ Config: Config{
FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(),
},
- Accounts: map[string]*Account{},
},
},
t: NewTransaction(
name: "when client requests to resume 1k test file at offset 256",
args: args{
cc: &ClientConn{
- transfers: map[int]map[[4]byte]*FileTransfer{
- FileDownload: {},
- }, Account: &Account{
+ ClientFileTransferMgr: NewClientFileTransferMgr(),
+ Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessDownloadFile)
+ bits.Set(AccessDownloadFile)
return bits
}(),
},
//
// return mfs
// }(),
- fileTransfers: map[[4]byte]*FileTransfer{},
- Config: &Config{
+ FileTransferMgr: NewMemFileTransferMgr(),
+ Config: Config{
FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(),
},
- Accounts: map[string]*Account{},
+ //Accounts: map[string]*Account{},
},
},
t: NewTransaction(
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: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
+ Access: accessBitmap{},
},
},
t: NewTransaction(
logger: NewTestLogger(),
Server: &Server{
Logger: NewTestLogger(),
- Accounts: map[string]*Account{
- "bbb": {},
- },
+ AccountManager: func() *MockAccountManager {
+ m := MockAccountManager{}
+ m.On("Get", "bbb").Return(&Account{})
+ return &m
+ }(),
},
Account: &Account{
Access: func() accessBitmap {
args: args{
cc: &ClientConn{
logger: NewTestLogger(),
- Server: &Server{
- Accounts: map[string]*Account{
- "bbb": {},
- },
- },
+ Server: &Server{},
Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
+ Access: accessBitmap{},
},
},
t: NewTransaction(
args: args{
cc: &ClientConn{
Server: &Server{
- Clients: map[[2]byte]*ClientConn{
- [2]byte{0, 1}: {
+ 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)
+ bits.Set(AccessCannotBeDiscon)
return bits
}(),
},
},
- },
+ )
+ return &m
+ }(),
},
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessDisconUser)
+ bits.Set(AccessDisconUser)
return bits
}(),
},
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessSendPrivMsg)
+ bits.Set(AccessSendPrivMsg)
return bits
}(),
},
ID: [2]byte{0, 1},
UserName: []byte("User1"),
Server: &Server{
- Clients: map[[2]byte]*ClientConn{
- [2]byte{0, 2}: {
+ 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(
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessSendPrivMsg)
+ bits.Set(AccessSendPrivMsg)
return bits
}(),
},
ID: [2]byte{0, 1},
UserName: []byte("User1"),
Server: &Server{
- Clients: map[[2]byte]*ClientConn{
- [2]byte{0, 2}: {
+ 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(
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessSendPrivMsg)
+ bits.Set(AccessSendPrivMsg)
return bits
}(),
},
ID: [2]byte{0, 1},
UserName: []byte("User1"),
Server: &Server{
- Clients: map[[2]byte]*ClientConn{
- [2]byte{0, 2}: {
+ 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(
}(),
},
Server: &Server{
- Config: &Config{
+ Config: Config{
FileRoot: func() string {
return "/fakeRoot/Files"
}(),
return mfs
}(),
- Accounts: map[string]*Account{},
+ //Accounts: map[string]*Account{},
},
},
t: NewTransaction(
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessDeleteFile)
+ bits.Set(AccessDeleteFile)
return bits
}(),
},
Server: &Server{
- Config: &Config{
+ Config: Config{
FileRoot: func() string {
return "/fakeRoot/Files"
}(),
return mfs
}(),
- Accounts: map[string]*Account{},
+ //Accounts: map[string]*Account{},
},
},
t: NewTransaction(
wantRes []Transaction
}{
{
- name: "when FieldFilePath is a drop box, but user does not have accessViewDropBoxes ",
+ name: "when FieldFilePath is a drop box, but user does not have AccessViewDropBoxes ",
args: args{
cc: &ClientConn{
Account: &Account{
},
Server: &Server{
- Config: &Config{
+ Config: Config{
FileRoot: func() string {
path, _ := os.Getwd()
return filepath.Join(path, "/test/config/Files/getFileNameListTestDir")
args: args{
cc: &ClientConn{
Server: &Server{
- Config: &Config{
+ Config: Config{
FileRoot: func() string {
path, _ := os.Getwd()
return filepath.Join(path, "/test/config/Files/getFileNameListTestDir")
}(),
},
Server: &Server{
- Accounts: map[string]*Account{},
+ //Accounts: map[string]*Account{},
},
},
t: NewTransaction(
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessGetClientInfo)
+ bits.Set(AccessGetClientInfo)
return bits
}(),
Name: "test",
Login: "test",
},
Server: &Server{
- Accounts: map[string]*Account{},
- Clients: map[[2]byte]*ClientConn{
- [2]byte{0, 1}: {
+ 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)
+ bits.Set(AccessGetClientInfo)
return bits
}(),
Name: "test",
Login: "test",
},
},
- },
- },
- transfers: map[int]map[[4]byte]*FileTransfer{
- FileDownload: {},
- FileUpload: {},
- FolderDownload: {},
- FolderUpload: {},
+ )
+ return &m
+ }(),
},
+ ClientFileTransferMgr: ClientFileTransferMgr{},
},
t: NewTransaction(
TranGetClientInfoText, [2]byte{0, 1},
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessDisconUser)
- bits.Set(accessAnyName)
+ bits.Set(AccessDisconUser)
+ bits.Set(AccessAnyName)
return bits
}()},
Icon: []byte{0, 1},
ID: [2]byte{0, 1},
logger: NewTestLogger(),
Server: &Server{
- Config: &Config{
+ 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(
wantRes []Transaction
}{
{
- name: "when client does not have accessAnyName",
+ name: "when client does not have AccessAnyName",
args: args{
cc: &ClientConn{
Account: &Account{
UserName: []byte("Guest"),
Flags: [2]byte{0, 1},
Server: &Server{
- Clients: map[[2]byte]*ClientConn{
- [2]byte{0, 1}: {
- ID: [2]byte{0, 1},
- },
- },
+ ClientMgr: func() *MockClientMgr {
+ m := MockClientMgr{}
+ m.On("List").Return([]*ClientConn{
+ {
+ ID: [2]byte{0, 1},
+ },
+ })
+ return &m
+ }(),
},
},
t: NewTransaction(
},
ID: [2]byte{0, 1},
Server: &Server{
- ThreadedNews: &ThreadedNews{Categories: map[string]NewsCategoryListData15{
- "test": {
- Type: [2]byte{0, 3},
- Name: "zz",
- },
- }},
+ ThreadedNewsMgr: func() *mockThreadNewsMgr {
+ m := mockThreadNewsMgr{}
+ m.On("NewsItem", []string{"test"}).Return(NewsCategoryListData15{
+ Type: NewsCategory,
+ })
+ return &m
+ }(),
},
},
t: NewTransaction(
},
ID: [2]byte{0, 1},
Server: &Server{
- ThreadedNews: &ThreadedNews{Categories: map[string]NewsCategoryListData15{
- "testcat": {
- Type: [2]byte{0, 2},
- Name: "test",
- },
- }},
+ ThreadedNewsMgr: func() *mockThreadNewsMgr {
+ m := mockThreadNewsMgr{}
+ m.On("NewsItem", []string{"test"}).Return(NewsCategoryListData15{
+ Type: NewsBundle,
+ })
+ return &m
+ }(),
},
},
t: NewTransaction(
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessNewsDeleteFldr)
+ bits.Set(AccessNewsDeleteFldr)
return bits
}(),
},
ID: [2]byte{0, 1},
Server: &Server{
- ConfigDir: "/fakeConfigRoot",
- FS: func() *MockFileStore {
- mfs := &MockFileStore{}
- mfs.On("WriteFile", "/fakeConfigRoot/ThreadedNews.yaml", mock.Anything, mock.Anything).Return(nil, os.ErrNotExist)
- return mfs
+ ThreadedNewsMgr: func() *mockThreadNewsMgr {
+ m := mockThreadNewsMgr{}
+ m.On("NewsItem", []string{"test"}).Return(NewsCategoryListData15{Type: NewsBundle})
+ m.On("DeleteNewsItem", []string{"test"}).Return(nil)
+ return &m
}(),
- ThreadedNews: &ThreadedNews{Categories: map[string]NewsCategoryListData15{
- "testcat": {
- Type: [2]byte{0, 2},
- Name: "test",
- },
- }},
},
},
t: NewTransaction(
args: args{
cc: &ClientConn{
Account: &Account{
- Access: func() accessBitmap {
- var bits accessBitmap
- return bits
- }(),
+ Access: accessBitmap{},
},
},
t: NewTransaction(
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessNewsPostArt)
+ bits.Set(AccessNewsPostArt)
return bits
}(),
},
Server: &Server{
- FS: func() *MockFileStore {
- mfs := &MockFileStore{}
- mfs.On("WriteFile", "/fakeConfigRoot/MessageBoard.txt", mock.Anything, mock.Anything).Return(nil, os.ErrNotExist)
- return mfs
+ 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
}(),
- ConfigDir: "/fakeConfigRoot",
- Config: &Config{},
},
},
t: NewTransaction(
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessOpenChat)
+ bits.Set(AccessOpenChat)
return bits
}(),
},
Icon: []byte{0, 1},
Flags: [2]byte{0, 0},
Server: &Server{
- Clients: map[[2]byte]*ClientConn{
- [2]byte{0, 2}: {
+ ClientMgr: func() *MockClientMgr {
+ m := MockClientMgr{}
+ m.On("Get", ClientID{0x0, 0x2}).Return(&ClientConn{
ID: [2]byte{0, 2},
UserName: []byte("UserB"),
- },
- },
- PrivateChats: make(map[[4]byte]*PrivateChat),
+ })
+ return &m
+ }(),
+ ChatMgr: func() *MockChatManager {
+ m := MockChatManager{}
+ m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(ChatID{0x52, 0xfd, 0xfc, 0x07})
+ return &m
+ }(),
},
},
t: NewTransaction(
NewField(FieldUserID, []byte{0, 1}),
},
},
-
{
clientID: [2]byte{0, 1},
IsReply: 0x01,
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessOpenChat)
+ bits.Set(AccessOpenChat)
return bits
}(),
},
Icon: []byte{0, 1},
Flags: [2]byte{0, 0},
Server: &Server{
- Clients: map[[2]byte]*ClientConn{
- [2]byte{0, 2}: {
+ 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},
- },
- },
- PrivateChats: make(map[[4]byte]*PrivateChat),
+ })
+ return &m
+ }(),
+ ChatMgr: func() *MockChatManager {
+ m := MockChatManager{}
+ m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(ChatID{0x52, 0xfd, 0xfc, 0x07})
+ return &m
+ }(),
},
},
t: NewTransaction(
}
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)
}{
{
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{
- Accounts: map[string]*Account{},
+ 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: 0x01,
- ErrorCode: [4]byte{0, 0, 0, 1},
+ IsReply: 1,
Fields: []Field{
- NewField(FieldError, []byte("You are not allowed to read news.")),
+ 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")),
},
},
},
}(),
},
Server: &Server{
- Accounts: map[string]*Account{},
+ //Accounts: map[string]*Account{},
},
},
t: NewTransaction(
},
},
},
- {
- 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{
- ThreadedNews: &ThreadedNews{
- Categories: map[string]NewsCategoryListData15{
- "Example Category": {
- Type: [2]byte{0, 2},
- Name: "",
- Articles: map[uint32]*NewsArtData{
- uint32(1): {
- Title: "testTitle",
- Poster: "testPoster",
- Data: "testBody",
- },
- },
- SubCats: nil,
- GUID: [16]byte{},
- AddSN: [4]byte{},
- DeleteSN: [4]byte{},
- },
- },
- },
-
- //Accounts: map[string]*Account{
- // "guest": {
- // Name: "guest",
- // Login: "guest",
- // Password: "zz",
- // Access: accessBitmap{255, 255, 255, 255, 255, 255, 255, 255},
- // },
- //},
- },
- },
- 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,
- },
- ),
- },
- },
- },
- },
+ //{
+ // 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) {
}(),
},
Server: &Server{
- Accounts: map[string]*Account{},
+ //Accounts: map[string]*Account{},
},
},
t: NewTransaction(
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessNewsCreateFldr)
+ bits.Set(AccessNewsCreateFldr)
return bits
}(),
},
logger: NewTestLogger(),
ID: [2]byte{0, 1},
Server: &Server{
- ConfigDir: "/fakeConfigRoot",
- FS: func() *MockFileStore {
- mfs := &MockFileStore{}
- mfs.On("WriteFile", "/fakeConfigRoot/ThreadedNews.yaml", mock.Anything, mock.Anything).Return(nil)
- return mfs
+ ThreadedNewsMgr: func() *mockThreadNewsMgr {
+ m := mockThreadNewsMgr{}
+ m.On("CreateGrouping", []string{"test"}, "testFolder", NewsBundle).Return(nil)
+ return &m
}(),
- ThreadedNews: &ThreadedNews{Categories: map[string]NewsCategoryListData15{
- "test": {
- Type: [2]byte{0, 2},
- Name: "test",
- SubCats: make(map[string]NewsCategoryListData15),
- },
- }},
},
},
t: NewTransaction(
// Account: &Account{
// Access: func() accessBitmap {
// var bits accessBitmap
- // bits.Set(accessNewsCreateFldr)
+ // bits.Set(AccessNewsCreateFldr)
// return bits
// }(),
// },
// logger: NewTestLogger(),
- // ID: [2]byte{0, 1},
+ // Type: [2]byte{0, 1},
// Server: &Server{
// ConfigDir: "/fakeConfigRoot",
// FS: func() *MockFileStore {
args: args{
cc: &ClientConn{
Server: &Server{
- FS: func() *MockFileStore {
- mfs := &MockFileStore{}
- mfs.On("WriteFile", "ThreadedNews.yaml", mock.Anything, mock.Anything).Return(nil, os.ErrNotExist)
- return mfs
+ ThreadedNewsMgr: func() *mockThreadNewsMgr {
+ m := mockThreadNewsMgr{}
+ m.On("PostArticle", []string{"www"}, uint32(0), mock.AnythingOfType("hotline.NewsArtData")).Return(nil)
+ return &m
}(),
- mux: sync.Mutex{},
- threadedNewsMux: sync.Mutex{},
- ThreadedNews: &ThreadedNews{
- Categories: map[string]NewsCategoryListData15{
- "www": {
- Type: [2]byte{},
- Name: "www",
- Articles: map[uint32]*NewsArtData{},
- SubCats: nil,
- GUID: [16]byte{},
- AddSN: [4]byte{},
- DeleteSN: [4]byte{},
- readOffset: 0,
- },
- },
- },
},
Account: &Account{
Access: func() accessBitmap {
var bits accessBitmap
- bits.Set(accessNewsPostArt)
+ bits.Set(AccessNewsPostArt)
return bits
}(),
},
"testing"
)
-func TestReadFields(t *testing.T) {
- type args struct {
- paramCount []byte
- buf []byte
- }
- tests := []struct {
- name string
- args args
- want []Field
- wantErr bool
- }{
- {
- name: "valid field data",
- args: args{
- paramCount: []byte{0x00, 0x02},
- buf: []byte{
- 0x00, 0x65, // ID: FieldData
- 0x00, 0x04, // Size: 2 bytes
- 0x01, 0x02, 0x03, 0x04, // Data
- 0x00, 0x66, // ID: FieldUserName
- 0x00, 0x02, // Size: 2 bytes
- 0x00, 0x01, // Data
- },
- },
- want: []Field{
- {
- ID: [2]byte{0x00, 0x65},
- FieldSize: [2]byte{0x00, 0x04},
- Data: []byte{0x01, 0x02, 0x03, 0x04},
- },
- {
- ID: [2]byte{0x00, 0x66},
- FieldSize: [2]byte{0x00, 0x02},
- Data: []byte{0x00, 0x01},
- },
- },
- wantErr: false,
- },
- {
- name: "empty bytes",
- args: args{
- paramCount: []byte{0x00, 0x00},
- buf: []byte{},
- },
- want: []Field(nil),
- wantErr: false,
- },
- {
- name: "when field size does not match data length",
- args: args{
- paramCount: []byte{0x00, 0x01},
- buf: []byte{
- 0x00, 0x65, // ID: FieldData
- 0x00, 0x04, // Size: 4 bytes
- 0x01, 0x02, 0x03, // Data
- },
- },
- want: []Field{},
- wantErr: true,
- },
- {
- name: "when field size of second field does not match data length",
- args: args{
- paramCount: []byte{0x00, 0x01},
- buf: []byte{
- 0x00, 0x65, // ID: FieldData
- 0x00, 0x02, // Size: 2 bytes
- 0x01, 0x02, // Data
- 0x00, 0x65, // ID: FieldData
- 0x00, 0x04, // Size: 4 bytes
- 0x01, 0x02, 0x03, // Data
- },
- },
- want: []Field{},
- wantErr: true,
- },
- {
- name: "when field data has extra bytes",
- args: args{
- paramCount: []byte{0x00, 0x01},
- buf: []byte{
- 0x00, 0x65, // ID: FieldData
- 0x00, 0x02, // Size: 2 bytes
- 0x01, 0x02, 0x03, // Data
- },
- },
- want: []Field{},
- wantErr: true,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got, err := ReadFields(tt.args.paramCount, tt.args.buf)
- if (err != nil) != tt.wantErr {
- t.Errorf("ReadFields() error = %v, wantErr %v", err, tt.wantErr)
- return
- }
-
- if !assert.Equal(t, tt.want, got) {
- t.Errorf("ReadFields() got = %v, want %v", got, tt.want)
- }
- })
- }
-}
-
func Test_transactionScanner(t *testing.T) {
type args struct {
data []byte
ParamCount: [2]byte{0, 1},
Fields: []Field{
{
- ID: FieldData,
+ Type: FieldData,
FieldSize: [2]byte{0, 3},
Data: []byte("hai"),
},
type transfer struct {
Protocol [4]byte // "HTXF" 0x48545846
- ReferenceNumber [4]byte // Unique ID generated for the transfer
+ ReferenceNumber [4]byte // Unique Type generated for the transfer
DataSize [4]byte // File size
RSVD [4]byte // Not implemented in Hotline Protocol
}
UserFlagRefusePChat = 3 // User refuses private chat
)
-// FieldOptions flags are sent from v1.5+ clients as part of TranAgreed
+// User options are sent from clients and represent options set in the client's preferences.
const (
UserOptRefusePM = 0 // User has "Refuse private messages" pref set
UserOptRefuseChat = 1 // User has "Refuse private chat" pref set
type UserFlags [2]byte
-func (flag *UserFlags) IsSet(i int) bool {
- flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(flag[:])))
+func (f *UserFlags) IsSet(i int) bool {
+ flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(f[:])))
return flagBitmap.Bit(i) == 1
}
-func (flag *UserFlags) Set(i int, newVal uint) {
- flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(flag[:])))
+func (f *UserFlags) Set(i int, newVal uint) {
+ flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(f[:])))
flagBitmap.SetBit(flagBitmap, i, newVal)
- binary.BigEndian.PutUint16(flag[:], uint16(flagBitmap.Int64()))
+ binary.BigEndian.PutUint16(f[:], uint16(flagBitmap.Int64()))
}
type User struct {
+++ /dev/null
-package hotline
-
-import (
- "encoding/binary"
- "errors"
-)
-
-func byteToInt(bytes []byte) (int, error) {
- switch len(bytes) {
- case 2:
- return int(binary.BigEndian.Uint16(bytes)), nil
- case 4:
- return int(binary.BigEndian.Uint32(bytes)), nil
- }
-
- return 0, errors.New("unknown byte length")
-}
+++ /dev/null
-package hotline
-
-import (
- "fmt"
- "github.com/stretchr/testify/assert"
- "testing"
-)
-
-func Test_byteToInt(t *testing.T) {
- type args struct {
- bytes []byte
- }
- tests := []struct {
- name string
- args args
- want int
- wantErr assert.ErrorAssertionFunc
- }{
- {
- name: "with 2 bytes of input",
- args: args{bytes: []byte{0, 1}},
- want: 1,
- wantErr: assert.NoError,
- },
- {
- name: "with 4 bytes of input",
- args: args{bytes: []byte{0, 1, 0, 0}},
- want: 65536,
- wantErr: assert.NoError,
- },
- {
- name: "with invalid number of bytes of input",
- args: args{bytes: []byte{1, 0, 0, 0, 0, 0, 0, 0}},
- want: 0,
- wantErr: assert.Error,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got, err := byteToInt(tt.args.bytes)
- if !tt.wantErr(t, err, fmt.Sprintf("byteToInt(%v)", tt.args.bytes)) {
- return
- }
- assert.Equalf(t, tt.want, got, "byteToInt(%v)", tt.args.bytes)
- })
- }
-}
--- /dev/null
+package mobius
+
+import (
+ "gopkg.in/yaml.v3"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+)
+
+type BanFile struct {
+ banList map[string]*time.Time
+ filePath string
+
+ sync.Mutex
+}
+
+func NewBanFile(path string) (*BanFile, error) {
+ bf := &BanFile{
+ filePath: path,
+ banList: make(map[string]*time.Time),
+ }
+
+ err := bf.Load()
+
+ return bf, err
+}
+
+func (bf *BanFile) Load() error {
+ bf.Lock()
+ defer bf.Unlock()
+
+ bf.banList = make(map[string]*time.Time)
+
+ fh, err := os.Open(bf.filePath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ defer fh.Close()
+
+ decoder := yaml.NewDecoder(fh)
+ err = decoder.Decode(&bf.banList)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (bf *BanFile) Add(ip string, until *time.Time) error {
+ bf.Lock()
+ defer bf.Unlock()
+
+ bf.banList[ip] = until
+
+ out, err := yaml.Marshal(bf.banList)
+ if err != nil {
+ return err
+ }
+
+ return os.WriteFile(filepath.Join(bf.filePath), out, 0644)
+}
+
+func (bf *BanFile) IsBanned(ip string) (bool, *time.Time) {
+ bf.Lock()
+ defer bf.Unlock()
+
+ if until, ok := bf.banList[ip]; ok {
+ return true, until
+ }
+
+ return false, nil
+}
--- /dev/null
+package mobius
+
+import (
+ "github.com/go-playground/validator/v10"
+ "github.com/jhalter/mobius/hotline"
+ "gopkg.in/yaml.v3"
+ "log"
+ "os"
+)
+
+func LoadConfig(path string) (*hotline.Config, error) {
+ var config hotline.Config
+
+ yamlFile, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ err = yaml.Unmarshal(yamlFile, &config)
+ if err != nil {
+ log.Fatalf("Unmarshal: %v", err)
+ }
+
+ validate := validator.New()
+ err = validate.Struct(config)
+ if err != nil {
+ return nil, err
+ }
+
+ return &config, nil
+}
--- /dev/null
+package mobius
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "slices"
+ "sync"
+)
+
+type FlatNews struct {
+ mu sync.Mutex
+
+ data []byte
+ filePath string
+
+ 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
+ }
+
+ return &FlatNews{
+ data: data,
+ filePath: path,
+ }, nil
+}
+
+func (f *FlatNews) Reload() error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
+ data, err := os.ReadFile(f.filePath)
+ if err != nil {
+ return err
+ }
+ f.data = data
+
+ return nil
+}
+
+// It returns the number of bytes read and any error encountered.
+func (f *FlatNews) Read(p []byte) (int, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
+ if f.readOffset >= len(f.data) {
+ return 0, io.EOF // All bytes have been read
+ }
+
+ n := copy(p, f.data[f.readOffset:])
+
+ f.readOffset += n
+
+ return n, nil
+}
+
+// Write implements io.Writer for flat news.
+// p is guaranteed to contain the full data of a news post.
+func (f *FlatNews) Write(p []byte) (int, error) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+
+ f.data = slices.Concat(p, f.data)
+
+ tempFilePath := f.filePath + ".tmp"
+
+ if err := os.WriteFile(tempFilePath, f.data, 0644); err != nil {
+ return 0, fmt.Errorf("write to temporary file: %v", err)
+ }
+
+ // Atomically rename the temporary file to the final file path.
+ if err := os.Rename(tempFilePath, f.filePath); err != nil {
+ return 0, fmt.Errorf("rename temporary file to final file: %v", err)
+ }
+
+ return len(p), os.WriteFile(f.filePath, f.data, 0644)
+}
+
+func (f *FlatNews) Seek(offset int64, _ int) (int64, error) {
+ f.readOffset = int(offset)
+
+ return 0, nil
+}
--- /dev/null
+package mobius
+
+import (
+ "cmp"
+ "encoding/binary"
+ "fmt"
+ "github.com/jhalter/mobius/hotline"
+ "gopkg.in/yaml.v3"
+ "os"
+ "slices"
+ "sort"
+ "sync"
+)
+
+type ThreadedNewsYAML struct {
+ ThreadedNews hotline.ThreadedNews
+
+ filePath string
+
+ mu sync.Mutex
+}
+
+func NewThreadedNewsYAML(filePath string) (*ThreadedNewsYAML, error) {
+ tn := &ThreadedNewsYAML{filePath: filePath}
+
+ err := tn.Load()
+
+ return tn, err
+}
+
+func (n *ThreadedNewsYAML) CreateGrouping(newsPath []string, name string, t [2]byte) error {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+
+ cats := n.getCatByPath(newsPath)
+ cats[name] = hotline.NewsCategoryListData15{
+ Name: name,
+ Type: t,
+ Articles: map[uint32]*hotline.NewsArtData{},
+ SubCats: make(map[string]hotline.NewsCategoryListData15),
+ }
+
+ return n.writeFile()
+}
+
+func (n *ThreadedNewsYAML) NewsItem(newsPath []string) hotline.NewsCategoryListData15 {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+
+ cats := n.ThreadedNews.Categories
+ delName := newsPath[len(newsPath)-1]
+ if len(newsPath) > 1 {
+ for _, fp := range newsPath[0 : len(newsPath)-1] {
+ cats = cats[fp].SubCats
+ }
+ }
+
+ return cats[delName]
+}
+
+func (n *ThreadedNewsYAML) DeleteNewsItem(newsPath []string) error {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+
+ cats := n.ThreadedNews.Categories
+ delName := newsPath[len(newsPath)-1]
+ if len(newsPath) > 1 {
+ for _, fp := range newsPath[0 : len(newsPath)-1] {
+ cats = cats[fp].SubCats
+ }
+ }
+
+ delete(cats, delName)
+
+ return n.writeFile()
+}
+
+func (n *ThreadedNewsYAML) GetArticle(newsPath []string, articleID uint32) *hotline.NewsArtData {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+
+ var cat hotline.NewsCategoryListData15
+ cats := n.ThreadedNews.Categories
+
+ for _, fp := range newsPath {
+ cat = cats[fp]
+ cats = cats[fp].SubCats
+ }
+
+ art := cat.Articles[articleID]
+ if art == nil {
+ return nil
+ }
+
+ 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()
+
+ var categories []hotline.NewsCategoryListData15
+ for _, c := range n.getCatByPath(paths) {
+ categories = append(categories, c)
+ }
+
+ slices.SortFunc(categories, func(a, b hotline.NewsCategoryListData15) int {
+ return cmp.Compare(
+ a.Name,
+ b.Name,
+ )
+ })
+
+ return categories
+}
+
+func (n *ThreadedNewsYAML) getCatByPath(paths []string) map[string]hotline.NewsCategoryListData15 {
+ cats := n.ThreadedNews.Categories
+ for _, path := range paths {
+ cats = cats[path].SubCats
+ }
+
+ return cats
+}
+
+func (n *ThreadedNewsYAML) PostArticle(newsPath []string, parentArticleID uint32, article hotline.NewsArtData) error {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+
+ binary.BigEndian.PutUint32(article.ParentArt[:], parentArticleID)
+
+ cats := n.getCatByPath(newsPath[:len(newsPath)-1])
+
+ catName := newsPath[len(newsPath)-1]
+ cat := cats[catName]
+
+ var keys []int
+ for k := range cat.Articles {
+ keys = append(keys, int(k))
+ }
+
+ nextID := uint32(1)
+ if len(keys) > 0 {
+ sort.Ints(keys)
+ prevID := uint32(keys[len(keys)-1])
+ nextID = prevID + 1
+
+ binary.BigEndian.PutUint32(article.PrevArt[:], prevID)
+
+ // Set next article Type
+ binary.BigEndian.PutUint32(cat.Articles[prevID].NextArt[:], nextID)
+ }
+
+ // Update parent article with first child reply
+ parentID := parentArticleID
+ if parentID != 0 {
+ parentArt := cat.Articles[parentID]
+
+ if parentArt.FirstChildArt == [4]byte{0, 0, 0, 0} {
+ binary.BigEndian.PutUint32(parentArt.FirstChildArt[:], nextID)
+ }
+ }
+
+ cat.Articles[nextID] = &article
+
+ cats[catName] = cat
+
+ return n.writeFile()
+}
+
+func (n *ThreadedNewsYAML) DeleteArticle(newsPath []string, articleID uint32, recursive bool) error {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+
+ if recursive {
+ // TODO: Handle delete recursive
+ }
+
+ cats := n.getCatByPath(newsPath[:len(newsPath)-1])
+
+ catName := newsPath[len(newsPath)-1]
+
+ cat := cats[catName]
+ delete(cat.Articles, articleID)
+ cats[catName] = cat
+
+ return n.writeFile()
+}
+
+func (n *ThreadedNewsYAML) ListArticles(newsPath []string) hotline.NewsArtListData {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+
+ var cat hotline.NewsCategoryListData15
+ cats := n.ThreadedNews.Categories
+
+ for _, fp := range newsPath {
+ cat = cats[fp]
+ cats = cats[fp].SubCats
+ }
+
+ return cat.GetNewsArtListData()
+}
+
+func (n *ThreadedNewsYAML) Load() error {
+ n.mu.Lock()
+ defer n.mu.Unlock()
+
+ fh, err := os.Open(n.filePath)
+ if err != nil {
+ return err
+ }
+ defer fh.Close()
+
+ n.ThreadedNews = hotline.ThreadedNews{}
+
+ decoder := yaml.NewDecoder(fh)
+ return decoder.Decode(&n.ThreadedNews)
+}
+
+func (n *ThreadedNewsYAML) writeFile() error {
+ out, err := yaml.Marshal(&n.ThreadedNews)
+ if err != nil {
+ return err
+ }
+
+ // Define a temporary file path in the same directory.
+ tempFilePath := n.filePath + ".tmp"
+
+ // Write the marshaled YAML to the temporary file.
+ if err := os.WriteFile(tempFilePath, out, 0644); err != nil {
+ return fmt.Errorf("write to temporary file: %v", err)
+ }
+
+ // Atomically rename the temporary file to the final file path.
+ if err := os.Rename(tempFilePath, n.filePath); err != nil {
+ return fmt.Errorf("rename temporary file to final file: %v", err)
+ }
+
+ return nil
+}