From 22c599abc18895f73e96095f35b71cf3357d41b4 Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Wed, 28 Jul 2021 18:21:52 -0700 Subject: Move code to hotline dir --- .run/go run server.go.run.xml | 12 - access.go | 59 - account.go | 54 - client.go | 1010 ------------- client/banners/1.txt | 6 - client/banners/2.txt | 9 - client/banners/3.txt | 6 - client/banners/4.txt | 7 - client/banners/5.txt | 7 - client/banners/6.txt | 6 - client/banners/7.txt | 7 - client/banners/8.txt | 10 - client/banners/9.txt | 9 - client/main.go | 3 +- client_conn.go | 247 --- client_conn_test.go | 53 - config.go | 21 - field.go | 120 -- field_test.go | 7 - file_header.go | 36 - file_header_test.go | 92 -- file_path.go | 56 - file_transfer.go | 29 - files.go | 155 -- files_test.go | 83 -- flattened_file_object.go | 239 --- flattened_file_object_test.go | 35 - hotline/access.go | 59 + hotline/account.go | 54 + hotline/banners/1.txt | 6 + hotline/banners/2.txt | 9 + hotline/banners/3.txt | 6 + hotline/banners/4.txt | 7 + hotline/banners/5.txt | 7 + hotline/banners/6.txt | 6 + hotline/banners/7.txt | 7 + hotline/banners/8.txt | 10 + hotline/banners/9.txt | 9 + hotline/client.go | 1010 +++++++++++++ hotline/client_conn.go | 247 +++ hotline/client_conn_test.go | 53 + hotline/config.go | 21 + hotline/field.go | 120 ++ hotline/field_test.go | 7 + hotline/file_header.go | 36 + hotline/file_header_test.go | 92 ++ hotline/file_path.go | 56 + hotline/file_transfer.go | 29 + hotline/files.go | 155 ++ hotline/files_test.go | 83 ++ hotline/flattened_file_object.go | 239 +++ hotline/flattened_file_object_test.go | 35 + hotline/news.go | 274 ++++ hotline/server.go | 1035 +++++++++++++ hotline/server_blackbox_test.go | 321 ++++ hotline/server_test.go | 806 ++++++++++ hotline/stats.go | 33 + hotline/test/config/Agreement.txt | 1 + hotline/test/config/Files/test/testfile-1k | Bin 0 -> 1024 bytes hotline/test/config/Files/test/testfile-5k | Bin 0 -> 5120 bytes .../test/config/Files/testdir/some-nested-file.txt | 0 hotline/test/config/Files/testfile.sit | 1 + hotline/test/config/Files/testfile.txt | 1 + hotline/test/config/MessageBoard.txt | 1 + hotline/test/config/ThreadedNews.yaml | 232 +++ hotline/test/config/Users/admin.yaml | 13 + hotline/test/config/Users/guest.yaml | 12 + hotline/test/config/config.yaml | 7 + hotline/tracker.go | 198 +++ hotline/tracker_test.go | 57 + hotline/transaction.go | 262 ++++ hotline/transaction_handlers.go | 1577 ++++++++++++++++++++ hotline/transaction_handlers_test.go | 394 +++++ hotline/transaction_test.go | 176 +++ hotline/transfer.go | 49 + hotline/transfer_test.go | 7 + hotline/user.go | 69 + hotline/user_test.go | 110 ++ hotline/version.go | 3 + news.go | 274 ---- server.go | 1035 ------------- server/main.go | 2 +- server_blackbox_test.go | 321 ---- server_test.go | 806 ---------- stats.go | 33 - test/.DS_Store | Bin 6148 -> 0 bytes test/config/.DS_Store | Bin 6148 -> 0 bytes test/config/Agreement.txt | 1 - test/config/Files/test/testfile-1k | Bin 1024 -> 0 bytes test/config/Files/test/testfile-5k | Bin 5120 -> 0 bytes test/config/Files/testdir/some-nested-file.txt | 0 test/config/Files/testfile.sit | 1 - test/config/Files/testfile.txt | 1 - test/config/MessageBoard.txt | 1 - test/config/ThreadedNews.yaml | 232 --- test/config/Users/.DS_Store | Bin 6148 -> 0 bytes test/config/Users/admin.yaml | 13 - test/config/Users/guest.yaml | 12 - test/config/config.yaml | 7 - tracker.go | 198 --- tracker_test.go | 57 - transaction.go | 262 ---- transaction_handlers.go | 1577 -------------------- transaction_handlers_test.go | 394 ----- transaction_test.go | 176 --- transfer.go | 49 - transfer_test.go | 7 - user.go | 69 - user_test.go | 110 -- version.go | 3 - 110 files changed, 8005 insertions(+), 8016 deletions(-) delete mode 100644 .run/go run server.go.run.xml delete mode 100644 access.go delete mode 100644 account.go delete mode 100644 client.go delete mode 100644 client/banners/1.txt delete mode 100644 client/banners/2.txt delete mode 100644 client/banners/3.txt delete mode 100644 client/banners/4.txt delete mode 100644 client/banners/5.txt delete mode 100644 client/banners/6.txt delete mode 100644 client/banners/7.txt delete mode 100644 client/banners/8.txt delete mode 100644 client/banners/9.txt delete mode 100644 client_conn.go delete mode 100644 client_conn_test.go delete mode 100644 config.go delete mode 100644 field.go delete mode 100644 field_test.go delete mode 100644 file_header.go delete mode 100644 file_header_test.go delete mode 100644 file_path.go delete mode 100644 file_transfer.go delete mode 100644 files.go delete mode 100644 files_test.go delete mode 100644 flattened_file_object.go delete mode 100644 flattened_file_object_test.go create mode 100644 hotline/access.go create mode 100644 hotline/account.go create mode 100644 hotline/banners/1.txt create mode 100644 hotline/banners/2.txt create mode 100644 hotline/banners/3.txt create mode 100644 hotline/banners/4.txt create mode 100644 hotline/banners/5.txt create mode 100644 hotline/banners/6.txt create mode 100644 hotline/banners/7.txt create mode 100644 hotline/banners/8.txt create mode 100644 hotline/banners/9.txt create mode 100644 hotline/client.go create mode 100644 hotline/client_conn.go create mode 100644 hotline/client_conn_test.go create mode 100644 hotline/config.go create mode 100644 hotline/field.go create mode 100644 hotline/field_test.go create mode 100644 hotline/file_header.go create mode 100644 hotline/file_header_test.go create mode 100644 hotline/file_path.go create mode 100644 hotline/file_transfer.go create mode 100644 hotline/files.go create mode 100644 hotline/files_test.go create mode 100644 hotline/flattened_file_object.go create mode 100644 hotline/flattened_file_object_test.go create mode 100644 hotline/news.go create mode 100644 hotline/server.go create mode 100644 hotline/server_blackbox_test.go create mode 100644 hotline/server_test.go create mode 100644 hotline/stats.go create mode 100644 hotline/test/config/Agreement.txt create mode 100644 hotline/test/config/Files/test/testfile-1k create mode 100644 hotline/test/config/Files/test/testfile-5k create mode 100644 hotline/test/config/Files/testdir/some-nested-file.txt create mode 100644 hotline/test/config/Files/testfile.sit create mode 100644 hotline/test/config/Files/testfile.txt create mode 100644 hotline/test/config/MessageBoard.txt create mode 100644 hotline/test/config/ThreadedNews.yaml create mode 100644 hotline/test/config/Users/admin.yaml create mode 100644 hotline/test/config/Users/guest.yaml create mode 100644 hotline/test/config/config.yaml create mode 100644 hotline/tracker.go create mode 100644 hotline/tracker_test.go create mode 100644 hotline/transaction.go create mode 100644 hotline/transaction_handlers.go create mode 100644 hotline/transaction_handlers_test.go create mode 100644 hotline/transaction_test.go create mode 100644 hotline/transfer.go create mode 100644 hotline/transfer_test.go create mode 100644 hotline/user.go create mode 100644 hotline/user_test.go create mode 100644 hotline/version.go delete mode 100644 news.go delete mode 100644 server.go delete mode 100644 server_blackbox_test.go delete mode 100644 server_test.go delete mode 100644 stats.go delete mode 100644 test/.DS_Store delete mode 100644 test/config/.DS_Store delete mode 100644 test/config/Agreement.txt delete mode 100644 test/config/Files/test/testfile-1k delete mode 100644 test/config/Files/test/testfile-5k delete mode 100644 test/config/Files/testdir/some-nested-file.txt delete mode 100644 test/config/Files/testfile.sit delete mode 100644 test/config/Files/testfile.txt delete mode 100644 test/config/MessageBoard.txt delete mode 100644 test/config/ThreadedNews.yaml delete mode 100644 test/config/Users/.DS_Store delete mode 100644 test/config/Users/admin.yaml delete mode 100644 test/config/Users/guest.yaml delete mode 100644 test/config/config.yaml delete mode 100644 tracker.go delete mode 100644 tracker_test.go delete mode 100644 transaction.go delete mode 100644 transaction_handlers.go delete mode 100644 transaction_handlers_test.go delete mode 100644 transaction_test.go delete mode 100644 transfer.go delete mode 100644 transfer_test.go delete mode 100644 user.go delete mode 100644 user_test.go delete mode 100644 version.go diff --git a/.run/go run server.go.run.xml b/.run/go run server.go.run.xml deleted file mode 100644 index efbc814..0000000 --- a/.run/go run server.go.run.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/access.go b/access.go deleted file mode 100644 index 21b438f..0000000 --- a/access.go +++ /dev/null @@ -1,59 +0,0 @@ -package hotline - -import ( - "encoding/binary" - "math/big" -) - -const ( - accessAlwaysAllow = -1 // Some transactions are always allowed - - // File System Maintenance - accessDeleteFile = 0 - accessUploadFile = 1 - accessDownloadFile = 2 // Can Download Files - accessRenameFile = 3 - accessMoveFile = 4 - accessCreateFolder = 5 - accessDeleteFolder = 6 - accessRenameFolder = 7 - accessMoveFolder = 8 - accessReadChat = 9 - accessSendChat = 10 - accessOpenChat = 11 - // accessCloseChat = 12 // Documented but unused? - // accessShowInList = 13 // Documented but unused? - accessCreateUser = 14 - accessDeleteUser = 15 - accessOpenUser = 16 - accessModifyUser = 17 - // accessChangeOwnPass = 18 // Documented but unused? - accessSendPrivMsg = 19 // This doesn't do what it seems like it should do. TODO: Investigate - accessNewsReadArt = 20 - accessNewsPostArt = 21 - accessDisconUser = 22 // Toggles red user name in user list - accessCannotBeDiscon = 23 - accessGetClientInfo = 24 - accessUploadAnywhere = 25 - accessAnyName = 26 - accessNoAgreement = 27 - accessSetFileComment = 28 - accessSetFolderComment = 29 - accessViewDropBoxes = 30 - accessMakeAlias = 31 - accessBroadcast = 32 - accessNewsDeleteArt = 33 - accessNewsCreateCat = 34 - accessNewsDeleteCat = 35 - accessNewsCreateFldr = 36 - accessNewsDeleteFldr = 37 -) - -func authorize(access *[]byte, accessBit int) bool { - if accessBit == accessAlwaysAllow { - return true - } - accessBitmap := big.NewInt(int64(binary.BigEndian.Uint64(*access))) - - return accessBitmap.Bit(63-accessBit) == 1 -} diff --git a/account.go b/account.go deleted file mode 100644 index b0e9ee5..0000000 --- a/account.go +++ /dev/null @@ -1,54 +0,0 @@ -package hotline - -import ( - "encoding/binary" - "github.com/jhalter/mobius/concat" -) - -const GuestAccount = "guest" // default account used when no login is provided for a connection - -type Account struct { - Login string `yaml:"Login"` - Name string `yaml:"Name"` - Password string `yaml:"Password"` - Access *[]byte `yaml:"Access"` // 8 byte bitmap -} - -// Payload marshals an account to byte slice -// Example: -// 00 04 // fieldCount? -// 00 66 // 102 - fieldUserName -// 00 0d // 13 -// 61 64 6d 69 6e 69 73 74 72 61 74 6f 72 // administrator -// 00 69 // 105 fieldUserLogin (encoded) -// 00 05 // len -// 9e 9b 92 96 91 // encoded login name -// 00 6a // 106 fieldUserPassword -// 00 01 // len -// 78 -// 00 6e // fieldUserAccess -// 00 08 -// ff d3 cf ef ff 80 00 00 -func (a *Account) Payload() (out []byte) { - nameLen := make([]byte, 2) - binary.BigEndian.PutUint16(nameLen, uint16(len(a.Name))) - - loginLen := make([]byte, 2) - binary.BigEndian.PutUint16(loginLen, uint16(len(a.Login))) - - return concat.Slices( - []byte{0x00, 0x3}, // param count -- always 3 - - []byte{0x00, 0x66}, // fieldUserName - nameLen, - []byte(a.Name), - - []byte{0x00, 0x69}, // fieldUserLogin - loginLen, - []byte(NegatedUserString([]byte(a.Login))), - - []byte{0x00, 0x6e}, // fieldUserAccess - []byte{0x00, 0x08}, - *a.Access, - ) -} diff --git a/client.go b/client.go deleted file mode 100644 index 940e6fe..0000000 --- a/client.go +++ /dev/null @@ -1,1010 +0,0 @@ -package hotline - -import ( - "bytes" - "embed" - "encoding/binary" - "errors" - "fmt" - "github.com/davecgh/go-spew/spew" - "github.com/gdamore/tcell/v2" - "github.com/rivo/tview" - "github.com/stretchr/testify/mock" - "go.uber.org/zap" - "gopkg.in/yaml.v2" - "io/ioutil" - "math/big" - "math/rand" - "net" - "os" - "strconv" - "strings" - "time" -) - -const clientConfigPath = "/usr/local/etc/mobius-client-config.yaml" -const ( - trackerListPage = "trackerList" -) - -//go:embed client/banners/*.txt -var bannerDir embed.FS - -type Bookmark struct { - Name string `yaml:"Name"` - Addr string `yaml:"Addr"` - Login string `yaml:"Login"` - Password string `yaml:"Password"` -} - -type ClientPrefs struct { - Username string `yaml:"Username"` - IconID int `yaml:"IconID"` - Bookmarks []Bookmark `yaml:"Bookmarks"` - Tracker string `yaml:"Tracker"` -} - -func (cp *ClientPrefs) IconBytes() []byte { - iconBytes := make([]byte, 2) - binary.BigEndian.PutUint16(iconBytes, uint16(cp.IconID)) - return iconBytes -} - -func readConfig(cfgPath string) (*ClientPrefs, error) { - fh, err := os.Open(cfgPath) - if err != nil { - return nil, err - } - - prefs := ClientPrefs{} - decoder := yaml.NewDecoder(fh) - decoder.SetStrict(true) - if err := decoder.Decode(&prefs); err != nil { - return nil, err - } - return &prefs, nil -} - -type Client struct { - DebugBuf *DebugBuffer - Connection net.Conn - Login *[]byte - Password *[]byte - Flags *[]byte - ID *[]byte - Version []byte - UserAccess []byte - Agreed bool - UserList []User - Logger *zap.SugaredLogger - activeTasks map[uint32]*Transaction - - pref *ClientPrefs - - Handlers map[uint16]clientTHandler - - UI *UI - - outbox chan *Transaction - Inbox chan *Transaction -} - -type UI struct { - chatBox *tview.TextView - chatInput *tview.InputField - App *tview.Application - Pages *tview.Pages - userList *tview.TextView - agreeModal *tview.Modal - trackerList *tview.List - settingsPage *tview.Box - HLClient *Client -} - -func NewUI(c *Client) *UI { - app := tview.NewApplication() - chatBox := tview.NewTextView(). - SetScrollable(true). - SetDynamicColors(true). - SetWordWrap(true). - SetChangedFunc(func() { - app.Draw() // TODO: docs say this is bad but it's the only way to show content during initial render?? - }) - chatBox.Box.SetBorder(true).SetTitle("Chat") - - chatInput := tview.NewInputField() - chatInput. - SetLabel("> "). - SetFieldBackgroundColor(tcell.ColorDimGray). - SetDoneFunc(func(key tcell.Key) { - // skip send if user hit enter with no other text - if len(chatInput.GetText()) == 0 { - return - } - - c.Send( - *NewTransaction(tranChatSend, nil, - NewField(fieldData, []byte(chatInput.GetText())), - ), - ) - chatInput.SetText("") // clear the input field after chat send - }) - - chatInput.Box.SetBorder(true).SetTitle("Send") - - userList := tview. - NewTextView(). - SetDynamicColors(true). - SetChangedFunc(func() { - app.Draw() // TODO: docs say this is bad but it's the only way to show content during initial render?? - }) - userList.Box.SetBorder(true).SetTitle("Users") - - return &UI{ - App: app, - chatBox: chatBox, - Pages: tview.NewPages(), - chatInput: chatInput, - userList: userList, - trackerList: tview.NewList(), - agreeModal: tview.NewModal(), - HLClient: c, - } -} - -func (ui *UI) showBookmarks() *tview.List { - list := tview.NewList() - list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - ui.Pages.SwitchToPage("home") - } - return event - }) - list.Box.SetBorder(true).SetTitle("| Bookmarks |") - - shortcut := 97 // rune for "a" - for i, srv := range ui.HLClient.pref.Bookmarks { - addr := srv.Addr - login := srv.Login - pass := srv.Password - list.AddItem(srv.Name, srv.Addr, rune(shortcut+i), func() { - ui.Pages.RemovePage("joinServer") - - newJS := ui.renderJoinServerForm(addr, login, pass, "bookmarks", true, true) - - ui.Pages.AddPage("joinServer", newJS, true, true) - }) - } - - return list -} - -func (ui *UI) getTrackerList() *tview.List { - listing, err := GetListing(ui.HLClient.pref.Tracker) - if err != nil { - spew.Dump(err) - } - - list := tview.NewList() - list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEsc { - ui.Pages.SwitchToPage("home") - } - return event - }) - list.Box.SetBorder(true).SetTitle("| Servers |") - - shortcut := 97 // rune for "a" - for i, srv := range listing { - addr := srv.Addr() - list.AddItem(string(srv.Name), string(srv.Description), rune(shortcut+i), func() { - ui.Pages.RemovePage("joinServer") - - newJS := ui.renderJoinServerForm(addr, GuestAccount, "", trackerListPage, false, true) - - ui.Pages.AddPage("joinServer", newJS, true, true) - ui.Pages.ShowPage("joinServer") - }) - } - - return list -} - -func (ui *UI) renderSettingsForm() *tview.Flex { - iconStr := strconv.Itoa(ui.HLClient.pref.IconID) - settingsForm := tview.NewForm() - settingsForm.AddInputField("Your Name", ui.HLClient.pref.Username, 0, nil, nil) - settingsForm.AddInputField("IconID", iconStr, 0, func(idStr string, _ rune) bool { - _, err := strconv.Atoi(idStr) - return err == nil - }, nil) - settingsForm.AddInputField("Tracker", ui.HLClient.pref.Tracker, 0, nil, nil) - settingsForm.AddButton("Save", func() { - usernameInput := settingsForm.GetFormItem(0).(*tview.InputField).GetText() - if len(usernameInput) == 0 { - usernameInput = "unnamed" - } - ui.HLClient.pref.Username = usernameInput - iconStr = settingsForm.GetFormItem(1).(*tview.InputField).GetText() - ui.HLClient.pref.IconID, _ = strconv.Atoi(iconStr) - ui.HLClient.pref.Tracker = settingsForm.GetFormItem(2).(*tview.InputField).GetText() - - out, err := yaml.Marshal(&ui.HLClient.pref) - if err != nil { - // TODO: handle err - } - // TODO: handle err - _ = ioutil.WriteFile(clientConfigPath, out, 0666) - ui.Pages.RemovePage("settings") - }) - settingsForm.SetBorder(true) - settingsForm.SetCancelFunc(func() { - ui.Pages.RemovePage("settings") - }) - settingsPage := tview.NewFlex().SetDirection(tview.FlexRow) - settingsPage.Box.SetBorder(true).SetTitle("Settings") - settingsPage.AddItem(settingsForm, 0, 1, true) - - centerFlex := tview.NewFlex(). - AddItem(nil, 0, 1, false). - AddItem(tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(nil, 0, 1, false). - AddItem(settingsForm, 15, 1, true). - AddItem(nil, 0, 1, false), 40, 1, true). - AddItem(nil, 0, 1, false) - - return centerFlex -} - -// DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger -type DebugBuffer struct { - TextView *tview.TextView -} - -func (db *DebugBuffer) Write(p []byte) (int, error) { - return db.TextView.Write(p) -} - -// Sync is a noop function that exists to satisfy the zapcore.WriteSyncer interface -func (db *DebugBuffer) Sync() error { - return nil -} - -func (ui *UI) joinServer(addr, login, password string) error { - if err := ui.HLClient.JoinServer(addr, login, password); err != nil { - return errors.New(fmt.Sprintf("Error joining server: %v\n", err)) - } - - go func() { - err := ui.HLClient.ReadLoop() - if err != nil { - ui.HLClient.Logger.Errorw("read error", "err", err) - } - }() - return nil -} - -func (ui *UI) renderJoinServerForm(server, login, password, backPage string, save, defaultConnect bool) *tview.Flex { - joinServerForm := tview.NewForm() - joinServerForm. - AddInputField("Server", server, 0, nil, nil). - AddInputField("Login", login, 0, nil, nil). - AddPasswordField("Password", password, 0, '*', nil). - AddCheckbox("Save", save, func(checked bool) { - // TODO: Implement bookmark saving - }). - AddButton("Cancel", func() { - ui.Pages.SwitchToPage(backPage) - }). - AddButton("Connect", func() { - err := ui.joinServer( - joinServerForm.GetFormItem(0).(*tview.InputField).GetText(), - joinServerForm.GetFormItem(1).(*tview.InputField).GetText(), - joinServerForm.GetFormItem(2).(*tview.InputField).GetText(), - ) - if err != nil { - ui.HLClient.Logger.Errorw("login error", "err", err) - loginErrModal := tview.NewModal(). - AddButtons([]string{"Oh no"}). - SetText(err.Error()). - SetDoneFunc(func(buttonIndex int, buttonLabel string) { - ui.Pages.SwitchToPage(backPage) - }) - - ui.Pages.AddPage("loginErr", loginErrModal, false, true) - } - - // Save checkbox - if joinServerForm.GetFormItem(3).(*tview.Checkbox).IsChecked() { - // TODO: implement bookmark saving - } - }) - - joinServerForm.Box.SetBorder(true).SetTitle("| Connect |") - joinServerForm.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - ui.Pages.SwitchToPage(backPage) - } - return event - }) - - if defaultConnect { - joinServerForm.SetFocus(5) - } - - joinServerPage := tview.NewFlex(). - AddItem(nil, 0, 1, false). - AddItem(tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(nil, 0, 1, false). - AddItem(joinServerForm, 14, 1, true). - AddItem(nil, 0, 1, false), 40, 1, true). - AddItem(nil, 0, 1, false) - - return joinServerPage -} - -func randomBanner() string { - rand.Seed(time.Now().UnixNano()) - - bannerFiles, _ := bannerDir.ReadDir("client/banners") - file, _ := bannerDir.ReadFile("client/banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name()) - - return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file) -} - -func (ui *UI) renderServerUI() *tview.Flex { - commandList := tview.NewTextView().SetDynamicColors(true) - commandList. - SetText("[yellow]^n[-::]: Read News [yellow]^p[-::]: Post News\n[yellow]^l[-::]: View Logs\n"). - SetBorder(true). - SetTitle("Keyboard Shortcuts") - - modal := tview.NewModal(). - SetText("Disconnect from the server?"). - AddButtons([]string{"Cancel", "Exit"}). - SetFocus(1) - modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) { - if buttonIndex == 1 { - _ = ui.HLClient.Disconnect() - ui.Pages.SwitchToPage("home") - } else { - ui.Pages.HidePage("modal") - } - }) - - serverUI := tview.NewFlex(). - AddItem(tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(commandList, 4, 0, false). - AddItem(ui.chatBox, 0, 8, false). - AddItem(ui.chatInput, 3, 0, true), 0, 1, true). - AddItem(ui.userList, 25, 1, false) - serverUI.SetBorder(true).SetTitle("| Mobius - Connected to " + "TODO" + " |").SetTitleAlign(tview.AlignLeft) - serverUI.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyEscape { - ui.Pages.AddPage("modal", modal, false, true) - } - - // Show News - if event.Key() == tcell.KeyCtrlN { - if err := ui.HLClient.Send(*NewTransaction(tranGetMsgs, nil)); err != nil { - ui.HLClient.Logger.Errorw("err", "err", err) - } - } - - // Post news - if event.Key() == tcell.KeyCtrlP { - - newsFlex := tview.NewFlex() - - newsPostTextArea := tview.NewTextView() - newsPostTextArea.SetBackgroundColor(tcell.ColorDimGray) - newsPostTextArea.SetChangedFunc(func() { - ui.App.Draw() // TODO: docs say this is bad but it's the only way to show content during initial render?? - }) - //newsPostTextArea.SetBorderPadding(0, 0, 1, 1) - - newsPostForm := tview.NewForm(). - SetButtonsAlign(tview.AlignRight). - AddButton("Post", nil) - newsPostForm.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyTab: - ui.App.SetFocus(newsPostTextArea) - case tcell.KeyEnter: - newsText := strings.ReplaceAll(newsPostTextArea.GetText(true), "\n", "\r") - err := ui.HLClient.Send( - *NewTransaction(tranOldPostNews, nil, - NewField(fieldData, []byte(newsText)), - ), - ) - if err != nil { - ui.HLClient.Logger.Errorw("Error posting news", "err", err) - // TODO: display errModal to user - } - //newsInput.SetText("") // clear the input field after chat send - ui.Pages.RemovePage("newsInput") - } - - return event - }) - - newsFlex. - SetDirection(tview.FlexRow). - SetBorder(true). - SetTitle("News Post") - - newsPostTextArea.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - ui.HLClient.Logger.Infow("key", "key", event.Key(), "rune", event.Rune()) - switch event.Key() { - case tcell.KeyEscape: - ui.Pages.RemovePage("newsInput") - case tcell.KeyTab: - ui.App.SetFocus(newsPostForm) - case tcell.KeyEnter: - fmt.Fprintf(newsPostTextArea, "\n") - default: - switch event.Rune() { - case 127: // backspace - curTxt := newsPostTextArea.GetText(true) - if len(curTxt) > 0 { - curTxt = curTxt[:len(curTxt)-1] - newsPostTextArea.SetText(curTxt) - } - default: - fmt.Fprintf(newsPostTextArea, string(event.Rune())) - } - } - - return event - }) - - newsFlex.AddItem(newsPostTextArea, 10, 0, true) - newsFlex.AddItem(newsPostForm, 3, 0, false) - - newsPostPage := tview.NewFlex(). - AddItem(nil, 0, 1, false). - AddItem(tview.NewFlex(). - SetDirection(tview.FlexRow). - AddItem(nil, 0, 1, false). - AddItem(newsFlex, 15, 1, true). - //AddItem(newsPostForm, 3, 0, false). - AddItem(nil, 0, 1, false), 40, 1, false). - AddItem(nil, 0, 1, false) - - ui.Pages.AddPage("newsInput", newsPostPage, true, true) - ui.App.SetFocus(newsPostTextArea) - } - - return event - }) - return serverUI -} - -func (ui *UI) Start() { - home := tview.NewFlex().SetDirection(tview.FlexRow) - home.Box.SetBorder(true).SetTitle("| Mobius v" + VERSION + " |").SetTitleAlign(tview.AlignLeft) - mainMenu := tview.NewList() - - bannerItem := tview.NewTextView(). - SetText(randomBanner()). - SetDynamicColors(true). - SetTextAlign(tview.AlignCenter) - - home.AddItem( - tview.NewFlex().AddItem(bannerItem, 0, 1, false), - 14, 1, false) - home.AddItem(tview.NewFlex(). - AddItem(nil, 0, 1, false). - AddItem(mainMenu, 0, 1, true). - AddItem(nil, 0, 1, false), - 0, 1, true, - ) - - mainMenu.AddItem("Join Server", "", 'j', func() { - joinServerPage := ui.renderJoinServerForm("", GuestAccount, "", "home", false, false) - ui.Pages.AddPage("joinServer", joinServerPage, true, true) - }). - AddItem("Bookmarks", "", 'b', func() { - ui.Pages.AddAndSwitchToPage("bookmarks", ui.showBookmarks(), true) - }). - AddItem("Browse Tracker", "", 't', func() { - ui.trackerList = ui.getTrackerList() - ui.Pages.AddAndSwitchToPage("trackerList", ui.trackerList, true) - }). - AddItem("Settings", "", 's', func() { - ui.Pages.AddPage("settings", ui.renderSettingsForm(), true, true) - }). - AddItem("Quit", "", 'q', func() { - ui.App.Stop() - }) - - ui.Pages.AddPage("home", home, true, true) - - // App level input capture - ui.App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - if event.Key() == tcell.KeyCtrlC { - ui.HLClient.Logger.Infow("Exiting") - ui.App.Stop() - os.Exit(0) - } - // Show Logs - if event.Key() == tcell.KeyCtrlL { - ui.HLClient.DebugBuf.TextView.ScrollToEnd() - ui.HLClient.DebugBuf.TextView.SetBorder(true).SetTitle("Logs") - ui.HLClient.DebugBuf.TextView.SetDoneFunc(func(key tcell.Key) { - if key == tcell.KeyEscape { - ui.Pages.RemovePage("logs") - } - }) - - ui.Pages.AddAndSwitchToPage("logs", ui.HLClient.DebugBuf.TextView, true) - } - return event - }) - - if err := ui.App.SetRoot(ui.Pages, true).SetFocus(ui.Pages).Run(); err != nil { - ui.App.Stop() - os.Exit(1) - } -} - -func NewClient(username string, logger *zap.SugaredLogger) *Client { - c := &Client{ - Logger: logger, - activeTasks: make(map[uint32]*Transaction), - Handlers: clientHandlers, - } - c.UI = NewUI(c) - - prefs, err := readConfig(clientConfigPath) - if err != nil { - return c - } - c.pref = prefs - - return c -} - -type clientTransaction struct { - Name string - Handler func(*Client, *Transaction) ([]Transaction, error) -} - -func (ch clientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) { - return ch.Handler(cc, t) -} - -type clientTHandler interface { - Handle(*Client, *Transaction) ([]Transaction, error) -} - -type mockClientHandler struct { - mock.Mock -} - -func (mh *mockClientHandler) Handle(cc *Client, t *Transaction) ([]Transaction, error) { - args := mh.Called(cc, t) - return args.Get(0).([]Transaction), args.Error(1) -} - -var clientHandlers = map[uint16]clientTHandler{ - // Server initiated - tranChatMsg: clientTransaction{ - Name: "tranChatMsg", - Handler: handleClientChatMsg, - }, - tranLogin: clientTransaction{ - Name: "tranLogin", - Handler: handleClientTranLogin, - }, - tranShowAgreement: clientTransaction{ - Name: "tranShowAgreement", - Handler: handleClientTranShowAgreement, - }, - tranUserAccess: clientTransaction{ - Name: "tranUserAccess", - Handler: handleClientTranUserAccess, - }, - tranGetUserNameList: clientTransaction{ - Name: "tranGetUserNameList", - Handler: handleClientGetUserNameList, - }, - tranNotifyChangeUser: clientTransaction{ - Name: "tranNotifyChangeUser", - Handler: handleNotifyChangeUser, - }, - tranNotifyDeleteUser: clientTransaction{ - Name: "tranNotifyDeleteUser", - Handler: handleNotifyDeleteUser, - }, - tranGetMsgs: clientTransaction{ - Name: "tranNotifyDeleteUser", - Handler: handleGetMsgs, - }, -} - -func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) { - newsText := string(t.GetField(fieldData).Data) - newsText = strings.ReplaceAll(newsText, "\r", "\n") - - newsTextView := tview.NewTextView(). - SetText(newsText). - SetDoneFunc(func(key tcell.Key) { - c.UI.Pages.SwitchToPage("serverUI") - c.UI.App.SetFocus(c.UI.chatInput) - }) - newsTextView.SetBorder(true).SetTitle("News") - - c.UI.Pages.AddPage("news", newsTextView, true, true) - c.UI.Pages.SwitchToPage("news") - c.UI.App.SetFocus(newsTextView) - - c.UI.App.Draw() - - return res, err -} - -func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) { - newUser := User{ - ID: t.GetField(fieldUserID).Data, - Name: string(t.GetField(fieldUserName).Data), - Icon: t.GetField(fieldUserIconID).Data, - Flags: t.GetField(fieldUserFlags).Data, - } - - // Possible cases: - // user is new to the server - // user is already on the server but has a new name - - var oldName string - var newUserList []User - updatedUser := false - for _, u := range c.UserList { - c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name) - if bytes.Equal(newUser.ID, u.ID) { - oldName = u.Name - u.Name = newUser.Name - if u.Name != newUser.Name { - _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n") - } - updatedUser = true - } - newUserList = append(newUserList, u) - } - - if !updatedUser { - newUserList = append(newUserList, newUser) - } - - c.UserList = newUserList - - c.renderUserList() - - return res, err -} - -func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) { - exitUser := t.GetField(fieldUserID).Data - - var newUserList []User - for _, u := range c.UserList { - if !bytes.Equal(exitUser, u.ID) { - newUserList = append(newUserList, u) - } - } - - c.UserList = newUserList - - c.renderUserList() - - return res, err -} - -const readBuffSize = 1024000 // 1KB - TODO: what should this be? - -func (c *Client) ReadLoop() error { - tranBuff := make([]byte, 0) - tReadlen := 0 - // Infinite loop where take action on incoming client requests until the connection is closed - for { - buf := make([]byte, readBuffSize) - tranBuff = tranBuff[tReadlen:] - - readLen, err := c.Connection.Read(buf) - if err != nil { - return err - } - tranBuff = append(tranBuff, buf[:readLen]...) - - // We may have read multiple requests worth of bytes from Connection.Read. readTransactions splits them - // into a slice of transactions - var transactions []Transaction - if transactions, tReadlen, err = readTransactions(tranBuff); err != nil { - c.Logger.Errorw("Error handling transaction", "err", err) - } - - // iterate over all of the transactions that were parsed from the byte slice and handle them - for _, t := range transactions { - if err := c.HandleTransaction(&t); err != nil { - c.Logger.Errorw("Error handling transaction", "err", err) - } - } - } -} - -func (c *Client) GetTransactions() error { - tranBuff := make([]byte, 0) - tReadlen := 0 - - buf := make([]byte, readBuffSize) - tranBuff = tranBuff[tReadlen:] - - readLen, err := c.Connection.Read(buf) - if err != nil { - return err - } - tranBuff = append(tranBuff, buf[:readLen]...) - - return nil -} - -func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) { - var users []User - for _, field := range t.Fields { - // The Hotline protocol docs say that ClientGetUserNameList should only return fieldUsernameWithInfo (300) - // fields, but shxd sneaks in fieldChatSubject (115) so it's important to filter explicitly for the expected - // field type. Probably a good idea to do everywhere. - if bytes.Equal(field.ID, []byte{0x01, 0x2c}) { - u, err := ReadUser(field.Data) - if err != nil { - return res, err - } - users = append(users, *u) - } - } - c.UserList = users - - c.renderUserList() - - return res, err -} - -func (c *Client) renderUserList() { - c.UI.userList.Clear() - for _, u := range c.UserList { - flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags))) - if flagBitmap.Bit(userFlagAdmin) == 1 { - _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name) - } else { - _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name) - } - } -} - -func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) { - _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(fieldData).Data) - - return res, err -} - -func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) { - c.UserAccess = t.GetField(fieldUserAccess).Data - - return res, err -} - -func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) { - agreement := string(t.GetField(fieldData).Data) - agreement = strings.ReplaceAll(agreement, "\r", "\n") - - c.UI.agreeModal = tview.NewModal(). - SetText(agreement). - AddButtons([]string{"Agree", "Disagree"}). - SetDoneFunc(func(buttonIndex int, buttonLabel string) { - if buttonIndex == 0 { - res = append(res, - *NewTransaction( - tranAgreed, nil, - NewField(fieldUserName, []byte(c.pref.Username)), - NewField(fieldUserIconID, c.pref.IconBytes()), - NewField(fieldUserFlags, []byte{0x00, 0x00}), - NewField(fieldOptions, []byte{0x00, 0x00}), - ), - ) - c.Agreed = true - c.UI.Pages.HidePage("agreement") - c.UI.App.SetFocus(c.UI.chatInput) - } else { - _ = c.Disconnect() - c.UI.Pages.SwitchToPage("home") - } - }, - ) - - c.Logger.Debug("show agreement page") - c.UI.Pages.AddPage("agreement", c.UI.agreeModal, false, true) - - c.UI.Pages.ShowPage("agreement ") - - c.UI.App.Draw() - return res, err -} - -func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) { - if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) { - errMsg := string(t.GetField(fieldError).Data) - errModal := tview.NewModal() - errModal.SetText(errMsg) - errModal.AddButtons([]string{"Oh no"}) - errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) { - c.UI.Pages.RemovePage("errModal") - }) - c.UI.Pages.RemovePage("joinServer") - c.UI.Pages.AddPage("errModal", errModal, false, true) - - c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf? - - c.Logger.Error(string(t.GetField(fieldError).Data)) - return nil, errors.New("login error: " + string(t.GetField(fieldError).Data)) - } - c.UI.Pages.AddAndSwitchToPage("serverUI", c.UI.renderServerUI(), true) - c.UI.App.SetFocus(c.UI.chatInput) - - if err := c.Send(*NewTransaction(tranGetUserNameList, nil)); err != nil { - c.Logger.Errorw("err", "err", err) - } - return res, err -} - -// JoinServer connects to a Hotline server and completes the login flow -func (c *Client) JoinServer(address, login, passwd string) error { - // Establish TCP connection to server - if err := c.connect(address); err != nil { - return err - } - - // Send handshake sequence - if err := c.Handshake(); err != nil { - return err - } - - // Authenticate (send tranLogin 107) - if err := c.LogIn(login, passwd); err != nil { - return err - } - - return nil -} - -// connect establishes a connection with a Server by sending handshake sequence -func (c *Client) connect(address string) error { - var err error - c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second) - if err != nil { - return err - } - return nil -} - -var ClientHandshake = []byte{ - 0x54, 0x52, 0x54, 0x50, // TRTP - 0x48, 0x4f, 0x54, 0x4c, // HOTL - 0x00, 0x01, - 0x00, 0x02, -} - -var ServerHandshake = []byte{ - 0x54, 0x52, 0x54, 0x50, // TRTP - 0x00, 0x00, 0x00, 0x00, // ErrorCode -} - -func (c *Client) Handshake() error { - //Protocol ID 4 ‘TRTP’ 0x54 52 54 50 - //Sub-protocol ID 4 User defined - //Version 2 1 Currently 1 - //Sub-version 2 User defined - if _, err := c.Connection.Write(ClientHandshake); err != nil { - return fmt.Errorf("handshake write err: %s", err) - } - - replyBuf := make([]byte, 8) - _, err := c.Connection.Read(replyBuf) - if err != nil { - return err - } - - //spew.Dump(replyBuf) - if bytes.Compare(replyBuf, ServerHandshake) == 0 { - return nil - } - // In the case of an error, client and server close the connection. - - return fmt.Errorf("handshake response err: %s", err) -} - -func (c *Client) LogIn(login string, password string) error { - return c.Send( - *NewTransaction( - tranLogin, nil, - NewField(fieldUserName, []byte(c.pref.Username)), - NewField(fieldUserIconID, c.pref.IconBytes()), - NewField(fieldUserLogin, []byte(NegatedUserString([]byte(login)))), - NewField(fieldUserPassword, []byte(NegatedUserString([]byte(password)))), - NewField(fieldVersion, []byte{0, 2}), - ), - ) -} - -func (c *Client) Send(t Transaction) error { - requestNum := binary.BigEndian.Uint16(t.Type) - tID := binary.BigEndian.Uint32(t.ID) - - //handler := TransactionHandlers[requestNum] - - // if transaction is NOT reply, add it to the list to transactions we're expecting a response for - if t.IsReply == 0 { - c.activeTasks[tID] = &t - } - - var n int - var err error - if n, err = c.Connection.Write(t.Payload()); err != nil { - return err - } - c.Logger.Debugw("Sent Transaction", - "IsReply", t.IsReply, - "type", requestNum, - "sentBytes", n, - ) - return nil -} - -func (c *Client) HandleTransaction(t *Transaction) error { - var origT Transaction - if t.IsReply == 1 { - requestID := binary.BigEndian.Uint32(t.ID) - origT = *c.activeTasks[requestID] - t.Type = origT.Type - } - - requestNum := binary.BigEndian.Uint16(t.Type) - c.Logger.Infow( - "Received Transaction", - "RequestType", requestNum, - ) - - if handler, ok := c.Handlers[requestNum]; ok { - outT, _ := handler.Handle(c, t) - for _, t := range outT { - c.Send(t) - } - } else { - c.Logger.Errorw( - "Unimplemented transaction type received", - "RequestID", requestNum, - "TransactionID", t.ID, - ) - } - - return nil -} - -func (c *Client) Connected() bool { - fmt.Printf("Agreed: %v UserAccess: %v\n", c.Agreed, c.UserAccess) - // c.Agreed == true && - if c.UserAccess != nil { - return true - } - return false -} - -func (c *Client) Disconnect() error { - err := c.Connection.Close() - if err != nil { - return err - } - return nil -} diff --git a/client/banners/1.txt b/client/banners/1.txt deleted file mode 100644 index 608a316..0000000 --- a/client/banners/1.txt +++ /dev/null @@ -1,6 +0,0 @@ - __ __ ______ ______ __ __ __ __ ______ -/\ \_\ \ /\ __ \ /\__ _\ /\ \ /\ \ /\ "-.\ \ /\ ___\ -\ \ __ \ \ \ \/\ \ \/_/\ \/ \ \ \____ \ \ \ \ \ \-. \ \ \ __\ - \ \_\ \_\ \ \_____\ \ \_\ \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ - \/_/\/_/ \/_____/ \/_/ \/_____/ \/_/ \/_/ \/_/ \/_____/ - \ No newline at end of file diff --git a/client/banners/2.txt b/client/banners/2.txt deleted file mode 100644 index 1121e06..0000000 --- a/client/banners/2.txt +++ /dev/null @@ -1,9 +0,0 @@ - ▄█ █▄ ▄██████▄ ███ ▄█ ▄█ ███▄▄▄▄ ▄████████ - ███ ███ ███ ███ ▀█████████▄ ███ ███ ███▀▀▀██▄ ███ ███ - ███ ███ ███ ███ ▀███▀▀██ ███ ███▌ ███ ███ ███ █▀ - ▄███▄▄▄▄███▄▄ ███ ███ ███ ▀ ███ ███▌ ███ ███ ▄███▄▄▄ -▀▀███▀▀▀▀███▀ ███ ███ ███ ███ ███▌ ███ ███ ▀▀███▀▀▀ - ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ █▄ - ███ ███ ███ ███ ███ ███▌ ▄ ███ ███ ███ ███ ███ - ███ █▀ ▀██████▀ ▄████▀ █████▄▄██ █▀ ▀█ █▀ ██████████ - ▀ \ No newline at end of file diff --git a/client/banners/3.txt b/client/banners/3.txt deleted file mode 100644 index 608a316..0000000 --- a/client/banners/3.txt +++ /dev/null @@ -1,6 +0,0 @@ - __ __ ______ ______ __ __ __ __ ______ -/\ \_\ \ /\ __ \ /\__ _\ /\ \ /\ \ /\ "-.\ \ /\ ___\ -\ \ __ \ \ \ \/\ \ \/_/\ \/ \ \ \____ \ \ \ \ \ \-. \ \ \ __\ - \ \_\ \_\ \ \_____\ \ \_\ \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ - \/_/\/_/ \/_____/ \/_/ \/_____/ \/_/ \/_/ \/_/ \/_____/ - \ No newline at end of file diff --git a/client/banners/4.txt b/client/banners/4.txt deleted file mode 100644 index 3b9d720..0000000 --- a/client/banners/4.txt +++ /dev/null @@ -1,7 +0,0 @@ -.___.__  ._______  _____._.___    .___ .______  ._______ -:   |  \ : .___  \ \__ _:||   |   : __|:      \ : .____/ -|   :   || :   |  |  |  :||   |   | : ||       || : _/\  -|   .   ||     :  |  |   ||   |/\ |   ||   |   ||   /  \ -|___|   | \_. ___/   |   ||   /  \|   ||___|   ||_.: __/ -    |___|   :/       |___||______/|___|    |___|   :/    -    :                                        diff --git a/client/banners/5.txt b/client/banners/5.txt deleted file mode 100644 index b6654e7..0000000 --- a/client/banners/5.txt +++ /dev/null @@ -1,7 +0,0 @@ - ▄▀▀▄ ▄▄ ▄▀▀▀▀▄ ▄▀▀▀█▀▀▄ ▄▀▀▀▀▄ ▄▀▀█▀▄ ▄▀▀▄ ▀▄ ▄▀▀█▄▄▄▄ -█ █ ▄▀ █ █ █ █ ▐ █ █ █ █ █ █ █ █ █ ▐ ▄▀ ▐ -▐ █▄▄▄█ █ █ ▐ █ ▐ █ ▐ █ ▐ ▐ █ ▀█ █▄▄▄▄▄ - █ █ ▀▄ ▄▀ █ █ █ █ █ █ ▌ - ▄▀ ▄▀ ▀▀▀▀ ▄▀ ▄▀▄▄▄▄▄▄▀ ▄▀▀▀▀▀▄ ▄▀ █ ▄▀▄▄▄▄ - █ █ █ █ █ █ █ ▐ █ ▐ - ▐ ▐ ▐ ▐ ▐ ▐ ▐ ▐ \ No newline at end of file diff --git a/client/banners/6.txt b/client/banners/6.txt deleted file mode 100644 index c8ac7a6..0000000 --- a/client/banners/6.txt +++ /dev/null @@ -1,6 +0,0 @@ - :: .: ... :::::::::::: ::: ::::::. :::..,:::::: - ,;; ;;, .;;;;;;;. ;;;;;;;;'''' ;;; ;;;`;;;;, `;;;;;;;'''' -,[[[,,,[[[ ,[[ \[[, [[ [[[ [[[ [[[[[. '[[ [[cccc -"$$$"""$$$ $$$, $$$ $$ $$' $$$ $$$ "Y$c$$ $$"""" - 888 "88o"888,_ _,88P 88, o88oo,.__888 888 Y88 888oo,__ - MMM YMM "YMMMMMP" MMM """"YUMMMMMM MMM YM """"YUMMM \ No newline at end of file diff --git a/client/banners/7.txt b/client/banners/7.txt deleted file mode 100644 index c072e25..0000000 --- a/client/banners/7.txt +++ /dev/null @@ -1,7 +0,0 @@ -██╗ ██╗ ██████╗ ████████╗██╗ ██╗███╗ ██╗███████╗ -██║ ██║██╔═══██╗╚══██╔══╝██║ ██║████╗ ██║██╔════╝ -███████║██║ ██║ ██║ ██║ ██║██╔██╗ ██║█████╗ -██╔══██║██║ ██║ ██║ ██║ ██║██║╚██╗██║██╔══╝ -██║ ██║╚██████╔╝ ██║ ███████╗██║██║ ╚████║███████╗ -╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝ - diff --git a/client/banners/8.txt b/client/banners/8.txt deleted file mode 100644 index d75e153..0000000 --- a/client/banners/8.txt +++ /dev/null @@ -1,10 +0,0 @@ - ██░ ██ ▒█████ ▄▄▄█████▓ ██▓ ██▓ ███▄ █ ▓█████ -▓██░ ██▒▒██▒ ██▒▓ ██▒ ▓▒▓██▒ ▓██▒ ██ ▀█ █ ▓█ ▀ -▒██▀▀██░▒██░ ██▒▒ ▓██░ ▒░▒██░ ▒██▒▓██ ▀█ ██▒▒███ -░▓█ ░██ ▒██ ██░░ ▓██▓ ░ ▒██░ ░██░▓██▒ ▐▌██▒▒▓█ ▄ -░▓█▒░██▓░ ████▓▒░ ▒██▒ ░ ░██████▒░██░▒██░ ▓██░░▒████▒ - ▒ ░░▒░▒░ ▒░▒░▒░ ▒ ░░ ░ ▒░▓ ░░▓ ░ ▒░ ▒ ▒ ░░ ▒░ ░ - ▒ ░▒░ ░ ░ ▒ ▒░ ░ ░ ░ ▒ ░ ▒ ░░ ░░ ░ ▒░ ░ ░ ░ - ░ ░░ ░░ ░ ░ ▒ ░ ░ ░ ▒ ░ ░ ░ ░ ░ - ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ - diff --git a/client/banners/9.txt b/client/banners/9.txt deleted file mode 100644 index eba9134..0000000 --- a/client/banners/9.txt +++ /dev/null @@ -1,9 +0,0 @@ - █████ █████ █████ ████ ███ -░░███ ░░███ ░░███ ░░███ ░░░ - ░███ ░███ ██████ ███████ ░███ ████ ████████ ██████ - ░███████████ ███░░███░░░███░ ░███ ░░███ ░░███░░███ ███░░███ - ░███░░░░░███ ░███ ░███ ░███ ░███ ░███ ░███ ░███ ░███████ - ░███ ░███ ░███ ░███ ░███ ███ ░███ ░███ ░███ ░███ ░███░░░ - █████ █████░░██████ ░░█████ █████ █████ ████ █████░░██████ -░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░ ░░░░░ ░░░░░░ - diff --git a/client/main.go b/client/main.go index 6008cad..3ce4c7e 100644 --- a/client/main.go +++ b/client/main.go @@ -4,7 +4,7 @@ import ( "context" "flag" "fmt" - hotline "github.com/jhalter/mobius" + "github.com/jhalter/mobius/hotline" "github.com/rivo/tview" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -14,6 +14,7 @@ import ( "syscall" ) + func main() { _, cancelRoot := context.WithCancel(context.Background()) diff --git a/client_conn.go b/client_conn.go deleted file mode 100644 index 5c25f92..0000000 --- a/client_conn.go +++ /dev/null @@ -1,247 +0,0 @@ -package hotline - -import ( - "bytes" - "encoding/binary" - "errors" - "golang.org/x/crypto/bcrypt" - "math/big" - "net" -) - -type byClientID []*ClientConn - -func (s byClientID) Len() int { - return len(s) -} - -func (s byClientID) Swap(i, j int) { - s[i], s[j] = s[j], s[i] -} - -func (s byClientID) Less(i, j int) bool { - return s[i].uint16ID() < s[j].uint16ID() -} - -// ClientConn represents a client connected to a Server -type ClientConn struct { - Connection net.Conn - ID *[]byte - Icon *[]byte - Flags *[]byte - UserName *[]byte - Account *Account - IdleTime *int - Server *Server - Version *[]byte - Idle bool - AutoReply *[]byte - Transfers map[int][]*FileTransfer -} - -func (cc *ClientConn) sendAll(t int, fields ...Field) { - for _, c := range sortedClients(cc.Server.Clients) { - cc.Server.outbox <- *NewTransaction(t, c.ID, fields...) - } -} - -func (cc *ClientConn) handleTransaction(transaction *Transaction) error { - requestNum := binary.BigEndian.Uint16(transaction.Type) - if handler, ok := TransactionHandlers[requestNum]; ok { - for _, reqField := range handler.RequiredFields { - field := transaction.GetField(reqField.ID) - - // Validate that required field is present - if field.ID == nil { - cc.Server.Logger.Infow( - "Missing required field", - "Account", cc.Account.Login, "UserName", string(*cc.UserName), "RequestType", handler.Name, "FieldID", reqField.ID, - ) - return nil - } - - if len(field.Data) < reqField.minLen { - cc.Server.Logger.Infow( - "Field does not meet minLen", - "Account", cc.Account.Login, "UserName", string(*cc.UserName), "RequestType", handler.Name, "FieldID", reqField.ID, - ) - return nil - } - } - if !authorize(cc.Account.Access, handler.Access) { - cc.Server.Logger.Infow( - "Unauthorized Action", - "Account", cc.Account.Login, "UserName", string(*cc.UserName), "RequestType", handler.Name, - ) - cc.Server.outbox <- cc.NewErrReply(transaction, handler.DenyMsg) - - return nil - } - - cc.Server.Logger.Infow( - "Received Transaction", - "login", cc.Account.Login, - "name", string(*cc.UserName), - "RequestType", handler.Name, - ) - - transactions, err := handler.Handler(cc, transaction) - if err != nil { - return err - } - for _, t := range transactions { - cc.Server.outbox <- t - } - } else { - cc.Server.Logger.Errorw( - "Unimplemented transaction type received", - "UserName", string(*cc.UserName), "RequestID", requestNum, - ) - } - - cc.Server.mux.Lock() - defer cc.Server.mux.Unlock() - - // if user was idle and this is a non-keepalive transaction - if *cc.IdleTime > userIdleSeconds && requestNum != tranKeepAlive { - flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(*cc.Flags))) - flagBitmap.SetBit(flagBitmap, userFlagAway, 0) - binary.BigEndian.PutUint16(*cc.Flags, uint16(flagBitmap.Int64())) - cc.Idle = false - //*cc.IdleTime = 0 - - cc.sendAll( - tranNotifyChangeUser, - NewField(fieldUserID, *cc.ID), - NewField(fieldUserFlags, *cc.Flags), - NewField(fieldUserName, *cc.UserName), - NewField(fieldUserIconID, *cc.Icon), - ) - - //return nil - } - - // TODO: Don't we need to skip this if requestNum == tranKeepalive ?? - *cc.IdleTime = 0 - - return nil -} - -func (cc *ClientConn) Authenticate(login string, password []byte) bool { - if account, ok := cc.Server.Accounts[login]; ok { - return bcrypt.CompareHashAndPassword([]byte(account.Password), password) == nil - } - - return false -} - -func (cc *ClientConn) uint16ID() uint16 { - id, _ := byteToInt(*cc.ID) - return uint16(id) -} - -// Authorize checks if the user account has the specified permission -func (cc *ClientConn) Authorize(access int) bool { - if access == 0 { - return true - } - - accessBitmap := big.NewInt(int64(binary.BigEndian.Uint64(*cc.Account.Access))) - - return accessBitmap.Bit(63-access) == 1 -} - -// Disconnect notifies other clients that a client has disconnected -func (cc ClientConn) Disconnect() { - cc.Server.mux.Lock() - defer cc.Server.mux.Unlock() - - delete(cc.Server.Clients, binary.BigEndian.Uint16(*cc.ID)) - - cc.NotifyOthers(*NewTransaction(tranNotifyDeleteUser, nil, NewField(fieldUserID, *cc.ID))) - - if err := cc.Connection.Close(); err != nil { - cc.Server.Logger.Errorw("error closing client connection", "RemoteAddr", cc.Connection.RemoteAddr()) - } -} - -// NotifyOthers sends transaction t to other clients connected to the server -func (cc ClientConn) NotifyOthers(t Transaction) { - for _, c := range sortedClients(cc.Server.Clients) { - if c.ID != cc.ID { - t.clientID = c.ID - cc.Server.outbox <- t - } - } -} - -type handshake struct { - Protocol [4]byte // Must be 0x54525450 TRTP - SubProtocol [4]byte - Version [2]byte // Always 1 - SubVersion [2]byte -} - -// Handshake -// After establishing TCP connection, both client and server start the handshake process -// in order to confirm that each of them comply with requirements of the other. -// The information provided in this initial data exchange identifies protocols, -// and their versions, used in the communication. In the case where, after inspection, -// the capabilities of one of the subjects do not comply with the requirements of the other, -// the connection is dropped. -// -// The following information is sent to the server: -// Description Size Data Note -// Protocol ID 4 TRTP 0x54525450 -// Sub-protocol ID 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 -//Error code 4 Error code returned by the server (0 = no error) -func Handshake(conn net.Conn, buf []byte) error { - var h handshake - r := bytes.NewReader(buf) - if err := binary.Read(r, binary.BigEndian, &h); err != nil { - return err - } - - if h.Protocol != [4]byte{0x54, 0x52, 0x54, 0x50} { - return errors.New("invalid handshake") - } - - _, err := conn.Write([]byte{84, 82, 84, 80, 0, 0, 0, 0}) - return err -} - -// NewReply returns a reply Transaction with fields for the ClientConn -func (cc *ClientConn) NewReply(t *Transaction, fields ...Field) Transaction { - reply := Transaction{ - Flags: 0x00, - IsReply: 0x01, - Type: t.Type, - ID: t.ID, - clientID: cc.ID, - ErrorCode: []byte{0, 0, 0, 0}, - Fields: fields, - } - - return reply -} - -// NewErrReply returns an error reply Transaction with errMsg -func (cc *ClientConn) NewErrReply(t *Transaction, errMsg string) Transaction { - return Transaction{ - clientID: cc.ID, - Flags: 0x00, - IsReply: 0x01, - Type: []byte{0, 0}, - ID: t.ID, - ErrorCode: []byte{0, 0, 0, 1}, - Fields: []Field{ - NewField(fieldError, []byte(errMsg)), - }, - } -} diff --git a/client_conn_test.go b/client_conn_test.go deleted file mode 100644 index d18f9b6..0000000 --- a/client_conn_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package hotline - -import ( - "net" - "testing" -) - -func TestClientConn_handleTransaction(t *testing.T) { - type fields struct { - Connection net.Conn - ID *[]byte - Icon *[]byte - Flags *[]byte - UserName *[]byte - Account *Account - IdleTime *int - Server *Server - Version *[]byte - Idle bool - AutoReply *[]byte - } - type args struct { - transaction *Transaction - } - tests := []struct { - name string - fields fields - args args - wantErr bool - }{ - // TODO: Add test cases. - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cc := &ClientConn{ - Connection: tt.fields.Connection, - ID: tt.fields.ID, - Icon: tt.fields.Icon, - Flags: tt.fields.Flags, - UserName: tt.fields.UserName, - Account: tt.fields.Account, - IdleTime: tt.fields.IdleTime, - Server: tt.fields.Server, - Version: tt.fields.Version, - Idle: tt.fields.Idle, - AutoReply: tt.fields.AutoReply, - } - if err := cc.handleTransaction(tt.args.transaction); (err != nil) != tt.wantErr { - t.Errorf("handleTransaction() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} \ No newline at end of file diff --git a/config.go b/config.go deleted file mode 100644 index e0c20cf..0000000 --- a/config.go +++ /dev/null @@ -1,21 +0,0 @@ -package hotline - -const ( - defaultAgreement = "This is an agreement. Say you agree.\r" - defaultMessageBoard = "Welcome to Hotline\r" - defaultThreadedNews = "Categories:\n" -) - -type Config struct { - Name string `yaml:"Name"` // Name used for Tracker registration - Description string `yaml:"Description"` // Description used for Tracker registration - BannerID int `yaml:"BannerID"` // Unimplemented - FileRoot string `yaml:"FileRoot"` // Path to Files - EnableTrackerRegistration bool `yaml:"EnableTrackerRegistration"` // Toggle Tracker Registration - Trackers []string `yaml:"Trackers"` // List of trackers that the server should register with - NewsDelimiter string `yaml:"NewsDelimiter"` // String used to separate news posts - NewsDateFormat string `yaml:"NewsDateFormat"` // Go template string to customize news date format - MaxDownloads int `yaml:"MaxDownloads"` // Global simultaneous download limit - MaxDownloadsPerClient int `yaml:"MaxDownloadsPerClient"` // Per client simultaneous download limit - MaxConnectionsPerIP int `yaml:"MaxConnectionsPerIP"` // Max connections per IP -} diff --git a/field.go b/field.go deleted file mode 100644 index 161a9b2..0000000 --- a/field.go +++ /dev/null @@ -1,120 +0,0 @@ -package hotline - -import ( - "encoding/binary" - "github.com/jhalter/mobius/concat" -) - -const fieldError = 100 -const fieldData = 101 -const fieldUserName = 102 -const fieldUserID = 103 -const fieldUserIconID = 104 -const fieldUserLogin = 105 -const fieldUserPassword = 106 -const fieldRefNum = 107 -const fieldTransferSize = 108 -const fieldChatOptions = 109 -const fieldUserAccess = 110 -const fieldUserAlias = 111 -const fieldUserFlags = 112 -const fieldOptions = 113 -const fieldChatID = 114 -const fieldChatSubject = 115 -const fieldWaitingCount = 116 -const fieldVersion = 160 -const fieldCommunityBannerID = 161 -const fieldServerName = 162 -const fieldFileNameWithInfo = 200 -const fieldFileName = 201 -const fieldFilePath = 202 -const fieldFileTypeString = 205 -const fieldFileCreatorString = 206 -const fieldFileSize = 207 -const fieldFileCreateDate = 208 -const fieldFileModifyDate = 209 -const fieldFileComment = 210 -const fieldFileNewName = 211 -const fieldFileNewPath = 212 -const fieldFileType = 213 -const fieldQuotingMsg = 214 // Defined but unused in the Hotline Protocol spec -const fieldAutomaticResponse = 215 -const fieldFolderItemCount = 220 -const fieldUsernameWithInfo = 300 -const fieldNewsArtListData = 321 -const fieldNewsCatName = 322 -const fieldNewsCatListData15 = 323 -const fieldNewsPath = 325 -const fieldNewsArtID = 326 -const fieldNewsArtDataFlav = 327 -const fieldNewsArtTitle = 328 -const fieldNewsArtPoster = 329 -const fieldNewsArtDate = 330 -const fieldNewsArtPrevArt = 331 -const fieldNewsArtNextArt = 332 -const fieldNewsArtData = 333 -const fieldNewsArtFlags = 334 -const fieldNewsArtParentArt = 335 -const fieldNewsArt1stChildArt = 336 -const fieldNewsArtRecurseDel = 337 - -type Field struct { - ID []byte // Type of field - FieldSize []byte // Size of the data part - Data []byte // Actual field content -} - -type requiredField struct { - ID int - minLen int - maxLen int -} - -func NewField(id uint16, data []byte) Field { - idBytes := make([]byte, 2) - binary.BigEndian.PutUint16(idBytes, id) - - bs := make([]byte, 2) - binary.BigEndian.PutUint16(bs, uint16(len(data))) - - return Field{ - ID: idBytes, - FieldSize: bs, - Data: data, - } -} - -func (f Field) Payload() []byte { - return concat.Slices(f.ID, f.FieldSize, f.Data) -} - -type FileNameWithInfo struct { - Type string // file type code - Creator []byte // File creator code - FileSize uint32 // File Size in bytes - NameScript []byte // TODO: What is this? - NameSize []byte // Length of name field - Name string // File name -} - -func (f FileNameWithInfo) Payload() []byte { - name := []byte(f.Name) - nameSize := make([]byte, 2) - binary.BigEndian.PutUint16(nameSize, uint16(len(name))) - - kb := f.FileSize - - fSize := make([]byte, 4) - binary.BigEndian.PutUint32(fSize, kb) - - return concat.Slices( - []byte(f.Type), - f.Creator, - fSize, - []byte{0, 0, 0, 0}, - f.NameScript, - nameSize, - []byte(f.Name), - ) - -} diff --git a/field_test.go b/field_test.go deleted file mode 100644 index fb686dc..0000000 --- a/field_test.go +++ /dev/null @@ -1,7 +0,0 @@ -package hotline - -import "testing" - -func TestHello(t *testing.T) { - -} diff --git a/file_header.go b/file_header.go deleted file mode 100644 index 60c652e..0000000 --- a/file_header.go +++ /dev/null @@ -1,36 +0,0 @@ -package hotline - -import ( - "encoding/binary" - "github.com/jhalter/mobius/concat" -) - -type FileHeader struct { - Size []byte // Total size of FileHeader payload - Type []byte // 0 for file, 1 for dir - FilePath []byte // encoded file path -} - -func NewFileHeader(fileName string, isDir bool) FileHeader { - fh := FileHeader{ - Size: make([]byte, 2), - Type: []byte{0x00, 0x00}, - FilePath: EncodeFilePath(fileName), - } - if isDir { - fh.Type = []byte{0x00, 0x01} - } - - encodedPathLen := uint16(len(fh.FilePath) + len(fh.Type)) - binary.BigEndian.PutUint16(fh.Size, encodedPathLen) - - return fh -} - -func (fh *FileHeader) Payload() []byte { - return concat.Slices( - fh.Size, - fh.Type, - fh.FilePath, - ) -} diff --git a/file_header_test.go b/file_header_test.go deleted file mode 100644 index 0194a60..0000000 --- a/file_header_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package hotline - -import ( - "reflect" - "testing" -) - -func TestNewFileHeader(t *testing.T) { - type args struct { - fileName string - isDir bool - } - tests := []struct { - name string - args args - want FileHeader - }{ - { - name: "when path is file", - args: args{ - fileName: "foo", - isDir: false, - }, - want: FileHeader{ - Size: []byte{0x00, 0x0a}, - Type: []byte{0x00, 0x00}, - FilePath: EncodeFilePath("foo"), - }, - }, - { - name: "when path is dir", - args: args{ - fileName: "foo", - isDir: true, - }, - want: FileHeader{ - Size: []byte{0x00, 0x0a}, - Type: []byte{0x00, 0x01}, - FilePath: EncodeFilePath("foo"), - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := NewFileHeader(tt.args.fileName, tt.args.isDir); !reflect.DeepEqual(got, tt.want) { - t.Errorf("NewFileHeader() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestFileHeader_Payload(t *testing.T) { - type fields struct { - Size []byte - Type []byte - FilePath []byte - } - tests := []struct { - name string - fields fields - want []byte - }{ - { - name: "has expected payload bytes", - fields: fields{ - Size: []byte{0x00, 0x0a}, - Type: []byte{0x00, 0x00}, - FilePath: EncodeFilePath("foo"), - }, - want: []byte{ - 0x00, 0x0a, // total size - 0x00, 0x00, // type - 0x00, 0x01, // path item count - 0x00, 0x00, // path separator - 0x03, // pathName len - 0x66, 0x6f, 0x6f, // "foo" - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - fh := &FileHeader{ - Size: tt.fields.Size, - Type: tt.fields.Type, - FilePath: tt.fields.FilePath, - } - if got := fh.Payload(); !reflect.DeepEqual(got, tt.want) { - t.Errorf("Payload() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/file_path.go b/file_path.go deleted file mode 100644 index 71168dc..0000000 --- a/file_path.go +++ /dev/null @@ -1,56 +0,0 @@ -package hotline - -import ( - "encoding/binary" - "strings" -) - -const pathSeparator = "/" // File path separator TODO: make configurable to support Windows - -// FilePathItem represents the file or directory portion of a delimited file path (e.g. foo and bar in "/foo/bar") -// 00 00 -// 09 -// 73 75 62 66 6f 6c 64 65 72 // "subfolder" -type FilePathItem struct { - Len byte - Name []byte -} - -func NewFilePathItem(b []byte) FilePathItem { - return FilePathItem{ - Len: b[2], - Name: b[3:], - } -} - -type FilePath struct { - PathItemCount []byte - PathItems []FilePathItem -} - -func NewFilePath(b []byte) FilePath { - if b == nil { - return FilePath{} - } - - fp := FilePath{PathItemCount: b[0:2]} - - // number of items in the path - pathItemLen := binary.BigEndian.Uint16(b[0:2]) - pathData := b[2:] - for i := uint16(0); i < pathItemLen; i++ { - segLen := pathData[2] - fp.PathItems = append(fp.PathItems, NewFilePathItem(pathData[:segLen+3])) - pathData = pathData[3+segLen:] - } - - return fp -} - -func (fp *FilePath) String() string { - var out []string - for _, i := range fp.PathItems { - out = append(out, string(i.Name)) - } - return strings.Join(out, pathSeparator) -} diff --git a/file_transfer.go b/file_transfer.go deleted file mode 100644 index 46b0061..0000000 --- a/file_transfer.go +++ /dev/null @@ -1,29 +0,0 @@ -package hotline - -import "fmt" - -// File transfer types -const ( - FileDownload = 0 - FileUpload = 1 - FolderDownload = 2 - FolderUpload = 3 -) - -type FileTransfer struct { - FileName []byte - FilePath []byte - ReferenceNumber []byte - Type int - TransferSize []byte // total size of all items in the folder. Only used in FolderUpload action - FolderItemCount []byte - BytesSent int - clientID uint16 -} - -func (ft *FileTransfer) String() string { - percentComplete := 10 - out := fmt.Sprintf("%s\t %v", ft.FileName, percentComplete) - - return out -} diff --git a/files.go b/files.go deleted file mode 100644 index 69c22b3..0000000 --- a/files.go +++ /dev/null @@ -1,155 +0,0 @@ -package hotline - -import ( - "encoding/binary" - "io/ioutil" - "os" - "path/filepath" - "strings" -) - -const defaultCreator = "TTXT" -const defaultType = "TEXT" - -var fileCreatorCodes = map[string]string{ - "sit": "SIT!", - "pdf": "CARO", -} - -var fileTypeCodes = map[string]string{ - "sit": "SIT!", - "jpg": "JPEG", - "pdf": "PDF ", -} - -func fileTypeFromFilename(fn string) string { - ext := strings.Split(fn, ".") - code := fileTypeCodes[ext[len(ext)-1]] - - if code == "" { - code = defaultType - } - - return code -} - -func fileCreatorFromFilename(fn string) string { - ext := strings.Split(fn, ".") - code := fileCreatorCodes[ext[len(ext)-1]] - if code == "" { - code = defaultCreator - } - - return code -} - -func getFileNameList(filePath string) ([]Field, error) { - var fields []Field - - files, err := ioutil.ReadDir(filePath) - if err != nil { - return fields, nil - } - - for _, file := range files { - var fileType string - var fileCreator []byte - var fileSize uint32 - if !file.IsDir() { - fileType = fileTypeFromFilename(file.Name()) - fileCreator = []byte(fileCreatorFromFilename(file.Name())) - fileSize = uint32(file.Size()) - } else { - fileType = "fldr" - fileCreator = make([]byte, 4) - - dir, err := ioutil.ReadDir(filePath + "/" + file.Name()) - if err != nil { - return fields, err - } - fileSize = uint32(len(dir)) - } - - fields = append(fields, NewField( - fieldFileNameWithInfo, - FileNameWithInfo{ - Type: fileType, - Creator: fileCreator, - FileSize: fileSize, - NameScript: []byte{0, 0}, - Name: file.Name(), - }.Payload(), - )) - } - - return fields, nil -} - -func CalcTotalSize(filePath string) ([]byte, error) { - var totalSize uint32 - err := filepath.Walk(filePath, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - if info.IsDir() { - return nil - } - - totalSize += uint32(info.Size()) - - return nil - }) - if err != nil { - return nil, err - } - - bs := make([]byte, 4) - binary.BigEndian.PutUint32(bs, totalSize) - - return bs, nil -} - -func CalcItemCount(filePath string) ([]byte, error) { - var itemcount uint16 - err := filepath.Walk(filePath, func(path string, info os.FileInfo, err error) error { - itemcount += 1 - - if err != nil { - return err - } - - return nil - }) - if err != nil { - return nil, err - } - - bs := make([]byte, 2) - binary.BigEndian.PutUint16(bs, itemcount-1) - - return bs, nil -} - -func EncodeFilePath(filePath string) []byte { - pathSections := strings.Split(filePath, "/") - pathItemCount := make([]byte, 2) - binary.BigEndian.PutUint16(pathItemCount, uint16(len(pathSections))) - - bytes := pathItemCount - - for _, section := range pathSections { - bytes = append(bytes, []byte{0, 0}...) - - pathStr := []byte(section) - bytes = append(bytes, byte(len(pathStr))) - bytes = append(bytes, pathStr...) - } - - return bytes -} - -func ReadFilePath(filePathFieldData []byte) string { - fp := NewFilePath(filePathFieldData) - return fp.String() -} diff --git a/files_test.go b/files_test.go deleted file mode 100644 index 88c893f..0000000 --- a/files_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package hotline - -import ( - "bytes" - "os" - "reflect" - "testing" -) - -func TestEncodeFilePath(t *testing.T) { - var tests = []struct { - filePath string - want []byte - }{ - { - filePath: "kitten1.jpg", - want: []byte{ - 0x00, 0x01, // number of items in path - 0x00, 0x00, // leading path separator - 0x0b, // length of next path section (11) - 0x6b, 0x69, 0x74, 0x74, 0x65, 0x6e, 0x31, 0x2e, 0x6a, 0x70, 0x67, // kitten1.jpg - }, - }, - { - filePath: "foo/kitten1.jpg", - want: []byte{ - 0x00, 0x02, // number of items in path - 0x00, 0x00, - 0x03, - 0x66, 0x6f, 0x6f, - 0x00, 0x00, // leading path separator - 0x0b, // length of next path section (11) - 0x6b, 0x69, 0x74, 0x74, 0x65, 0x6e, 0x31, 0x2e, 0x6a, 0x70, 0x67, // kitten1.jpg - }, - }, - } - - for _, test := range tests { - got := EncodeFilePath(test.filePath) - if !bytes.Equal(got, test.want) { - t.Errorf("field mismatch: want: %#v got: %#v", test.want, got) - } - } -} - -func TestCalcTotalSize(t *testing.T) { - cwd, _ := os.Getwd() - defer func() {_ = os.Chdir(cwd)}() - - _ = os.Chdir("test/config/Files") - - type args struct { - filePath string - } - tests := []struct { - name string - args args - want []byte - wantErr bool - }{ - { - name: "Foo", - args: args{ - filePath: "test", - }, - want: []byte{0x00, 0x00, 0x18, 0x00}, - wantErr: false, - }, - // TODO: Add more test cases. - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := CalcTotalSize(tt.args.filePath) - if (err != nil) != tt.wantErr { - t.Errorf("CalcTotalSize() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("CalcTotalSize() got = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/flattened_file_object.go b/flattened_file_object.go deleted file mode 100644 index dfdd8a8..0000000 --- a/flattened_file_object.go +++ /dev/null @@ -1,239 +0,0 @@ -package hotline - -import ( - "encoding/binary" - "fmt" - "os" -) - -type flattenedFileObject struct { - FlatFileHeader FlatFileHeader - FlatFileInformationForkHeader FlatFileInformationForkHeader - FlatFileInformationFork FlatFileInformationFork - FlatFileDataForkHeader FlatFileDataForkHeader - FileData []byte -} - -// FlatFileHeader is the first section of a "Flattened File Object". All fields have static values. -type FlatFileHeader struct { - Format []byte // Always "FILP" - Version []byte // Always 1 - RSVD []byte // Always empty zeros - ForkCount []byte // Always 2 -} - -// NewFlatFileHeader returns a FlatFileHeader struct -func NewFlatFileHeader() FlatFileHeader { - return FlatFileHeader{ - Format: []byte("FILP"), - Version: []byte{0, 1}, - RSVD: make([]byte, 16), - ForkCount: []byte{0, 2}, - } -} - -// FlatFileInformationForkHeader is the second section of a "Flattened File Object" -type FlatFileInformationForkHeader struct { - ForkType []byte // Always "INFO" - CompressionType []byte // Always 0; Compression was never implemented in the Hotline protocol - RSVD []byte // Always zeros - DataSize []byte // Size of the flat file information fork -} - -type FlatFileInformationFork struct { - Platform []byte // Operating System used. ("AMAC" or "MWIN") - TypeSignature []byte // File type signature - CreatorSignature []byte // File creator signature - Flags []byte - PlatformFlags []byte - RSVD []byte - CreateDate []byte - ModifyDate []byte - NameScript []byte // TODO: what is this? - NameSize []byte // Length of file name (Maximum 128 characters) - Name []byte // File name - CommentSize []byte // Length of file comment - Comment []byte // File comment -} - -func NewFlatFileInformationFork(fileName string) FlatFileInformationFork { - return FlatFileInformationFork{ - Platform: []byte("AMAC"), // TODO: Remove hardcode to support "AWIN" Platform (maybe?) - TypeSignature: []byte(fileTypeFromFilename(fileName)), // TODO: Don't infer types from filename - CreatorSignature: []byte(fileCreatorFromFilename(fileName)), // TODO: Don't infer types from filename - Flags: []byte{0, 0, 0, 0}, // TODO: What is this? - PlatformFlags: []byte{0, 0, 1, 0}, // TODO: What is this? - RSVD: make([]byte, 32), // Unimplemented in Hotline Protocol - CreateDate: []byte{0x07, 0x70, 0x00, 0x00, 0xba, 0x74, 0x24, 0x73}, // TODO: implement - ModifyDate: []byte{0x07, 0x70, 0x00, 0x00, 0xba, 0x74, 0x24, 0x73}, // TODO: implement - NameScript: make([]byte, 2), // TODO: What is this? - Name: []byte(fileName), - Comment: []byte("TODO"), // TODO: implement (maybe?) - } -} - -// Size of the flat file information fork, which is the fixed size of 72 bytes -// plus the number of bytes in the FileName -// TODO: plus the size of the Comment! -func (ffif FlatFileInformationFork) DataSize() []byte { - size := make([]byte, 4) - nameLen := len(ffif.Name) - //TODO: Can I do math directly on two byte slices? - dataSize := nameLen + 74 - - binary.BigEndian.PutUint32(size, uint32(dataSize)) - - return size -} - -func (ffo flattenedFileObject) TransferSize() []byte { - payloadSize := len(ffo.Payload()) - dataSize := binary.BigEndian.Uint32(ffo.FlatFileDataForkHeader.DataSize) - - transferSize := make([]byte, 4) - binary.BigEndian.PutUint32(transferSize, dataSize+uint32(payloadSize)) - - return transferSize -} - -func (ffif FlatFileInformationFork) ReadNameSize() []byte { - size := make([]byte, 2) - binary.BigEndian.PutUint16(size, uint16(len(ffif.Name))) - - return size -} - -type FlatFileDataForkHeader struct { - ForkType []byte - CompressionType []byte - RSVD []byte - DataSize []byte -} - -func NewFlatFileDataForkHeader() FlatFileDataForkHeader { - return FlatFileDataForkHeader{ - ForkType: []byte("DATA"), - CompressionType: []byte{0, 0, 0, 0}, - RSVD: []byte{0, 0, 0, 0}, - // DataSize: []byte{0, 0, 0x03, 0xc3}, - } -} - -// ReadFlattenedFileObject parses a byte slice into a flattenedFileObject -func ReadFlattenedFileObject(bytes []byte) flattenedFileObject { - nameSize := bytes[110:112] - bs := binary.BigEndian.Uint16(nameSize) - - nameEnd := 112 + bs - - commentSize := bytes[nameEnd : nameEnd+2] - commentLen := binary.BigEndian.Uint16(commentSize) - - commentStartPos := int(nameEnd) + 2 - commentEndPos := int(nameEnd) + 2 + int(commentLen) - - comment := bytes[commentStartPos:commentEndPos] - - //dataSizeField := bytes[nameEnd+14+commentLen : nameEnd+18+commentLen] - //dataSize := binary.BigEndian.Uint32(dataSizeField) - - ffo := flattenedFileObject{ - FlatFileHeader: FlatFileHeader{ - Format: bytes[0:4], - Version: bytes[4:6], - RSVD: bytes[6:22], - ForkCount: bytes[22:24], - }, - FlatFileInformationForkHeader: FlatFileInformationForkHeader{ - ForkType: bytes[24:28], - CompressionType: bytes[28:32], - RSVD: bytes[32:36], - DataSize: bytes[36:40], - }, - FlatFileInformationFork: FlatFileInformationFork{ - Platform: bytes[40:44], - TypeSignature: bytes[44:48], - CreatorSignature: bytes[48:52], - Flags: bytes[52:56], - PlatformFlags: bytes[56:60], - RSVD: bytes[60:92], - CreateDate: bytes[92:100], - ModifyDate: bytes[100:108], - NameScript: bytes[108:110], - NameSize: bytes[110:112], - Name: bytes[112:nameEnd], - CommentSize: bytes[nameEnd : nameEnd+2], - Comment: comment, - }, - FlatFileDataForkHeader: FlatFileDataForkHeader{ - ForkType: bytes[commentEndPos : commentEndPos+4], - CompressionType: bytes[commentEndPos+4 : commentEndPos+8], - RSVD: bytes[commentEndPos+8 : commentEndPos+12], - DataSize: bytes[commentEndPos+12 : commentEndPos+16], - }, - } - - return ffo -} - -func (f flattenedFileObject) Payload() []byte { - var out []byte - out = append(out, f.FlatFileHeader.Format...) - out = append(out, f.FlatFileHeader.Version...) - out = append(out, f.FlatFileHeader.RSVD...) - out = append(out, f.FlatFileHeader.ForkCount...) - - out = append(out, []byte("INFO")...) - out = append(out, []byte{0, 0, 0, 0}...) - out = append(out, make([]byte, 4)...) - out = append(out, f.FlatFileInformationFork.DataSize()...) - - out = append(out, f.FlatFileInformationFork.Platform...) - out = append(out, f.FlatFileInformationFork.TypeSignature...) - out = append(out, f.FlatFileInformationFork.CreatorSignature...) - out = append(out, f.FlatFileInformationFork.Flags...) - out = append(out, f.FlatFileInformationFork.PlatformFlags...) - out = append(out, f.FlatFileInformationFork.RSVD...) - out = append(out, f.FlatFileInformationFork.CreateDate...) - out = append(out, f.FlatFileInformationFork.ModifyDate...) - out = append(out, f.FlatFileInformationFork.NameScript...) - out = append(out, f.FlatFileInformationFork.ReadNameSize()...) - out = append(out, f.FlatFileInformationFork.Name...) - - // TODO: Implement commentlen and comment field - out = append(out, []byte{0, 0}...) - - out = append(out, f.FlatFileDataForkHeader.ForkType...) - out = append(out, f.FlatFileDataForkHeader.CompressionType...) - out = append(out, f.FlatFileDataForkHeader.RSVD...) - out = append(out, f.FlatFileDataForkHeader.DataSize...) - - return out -} - -func NewFlattenedFileObject(filePath string, fileName string) (flattenedFileObject, error) { - file, err := os.Open(fmt.Sprintf("%v/%v", filePath, fileName)) - if err != nil { - return flattenedFileObject{}, err - } - defer file.Close() - - fileInfo, err := file.Stat() - if err != nil { - return flattenedFileObject{}, err - } - - dataSize := make([]byte, 4) - binary.BigEndian.PutUint32(dataSize, uint32(fileInfo.Size())) - - return flattenedFileObject{ - FlatFileHeader: NewFlatFileHeader(), - FlatFileInformationFork: NewFlatFileInformationFork(fileName), - FlatFileDataForkHeader: FlatFileDataForkHeader{ - ForkType: []byte("DATA"), - CompressionType: []byte{0, 0, 0, 0}, - RSVD: []byte{0, 0, 0, 0}, - DataSize: dataSize, - }, - }, nil -} diff --git a/flattened_file_object_test.go b/flattened_file_object_test.go deleted file mode 100644 index 6a6953f..0000000 --- a/flattened_file_object_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package hotline - -import ( - "bytes" - "encoding/hex" - "testing" -) - -func TestReadFlattenedFileObject(t *testing.T) { - testData, _ := hex.DecodeString("46494c500001000000000000000000000000000000000002494e464f000000000000000000000052414d414354455854747478740000000000000100000000000000000000000000000000000000000000000000000000000000000007700000ba74247307700000ba74247300000008746573742e74787400004441544100000000000000000000000474657374") - - ffo := ReadFlattenedFileObject(testData) - - format := ffo.FlatFileHeader.Format - want := []byte("FILP") - if !bytes.Equal(format, want) { - t.Errorf("ReadFlattenedFileObject() = %q, want %q", format, want) - } -} -// -//func TestNewFlattenedFileObject(t *testing.T) { -// ffo := NewFlattenedFileObject("test/config/files", "testfile.txt") -// -// dataSize := ffo.FlatFileDataForkHeader.DataSize -// want := []byte{0, 0, 0, 0x17} -// if bytes.Compare(dataSize, want) != 0 { -// t.Errorf("%q, want %q", dataSize, want) -// } -// -// comment := ffo.FlatFileInformationFork.Comment -// want = []byte("Test Comment") -// if bytes.Compare(ffo.FlatFileInformationFork.Comment, want) != 0 { -// t.Errorf("%q, want %q", comment, want) -// } -//} diff --git a/hotline/access.go b/hotline/access.go new file mode 100644 index 0000000..21b438f --- /dev/null +++ b/hotline/access.go @@ -0,0 +1,59 @@ +package hotline + +import ( + "encoding/binary" + "math/big" +) + +const ( + accessAlwaysAllow = -1 // Some transactions are always allowed + + // File System Maintenance + accessDeleteFile = 0 + accessUploadFile = 1 + accessDownloadFile = 2 // Can Download Files + accessRenameFile = 3 + accessMoveFile = 4 + accessCreateFolder = 5 + accessDeleteFolder = 6 + accessRenameFolder = 7 + accessMoveFolder = 8 + accessReadChat = 9 + accessSendChat = 10 + accessOpenChat = 11 + // accessCloseChat = 12 // Documented but unused? + // accessShowInList = 13 // Documented but unused? + accessCreateUser = 14 + accessDeleteUser = 15 + accessOpenUser = 16 + accessModifyUser = 17 + // accessChangeOwnPass = 18 // Documented but unused? + accessSendPrivMsg = 19 // This doesn't do what it seems like it should do. TODO: Investigate + accessNewsReadArt = 20 + accessNewsPostArt = 21 + accessDisconUser = 22 // Toggles red user name in user list + accessCannotBeDiscon = 23 + accessGetClientInfo = 24 + accessUploadAnywhere = 25 + accessAnyName = 26 + accessNoAgreement = 27 + accessSetFileComment = 28 + accessSetFolderComment = 29 + accessViewDropBoxes = 30 + accessMakeAlias = 31 + accessBroadcast = 32 + accessNewsDeleteArt = 33 + accessNewsCreateCat = 34 + accessNewsDeleteCat = 35 + accessNewsCreateFldr = 36 + accessNewsDeleteFldr = 37 +) + +func authorize(access *[]byte, accessBit int) bool { + if accessBit == accessAlwaysAllow { + return true + } + accessBitmap := big.NewInt(int64(binary.BigEndian.Uint64(*access))) + + return accessBitmap.Bit(63-accessBit) == 1 +} diff --git a/hotline/account.go b/hotline/account.go new file mode 100644 index 0000000..b0e9ee5 --- /dev/null +++ b/hotline/account.go @@ -0,0 +1,54 @@ +package hotline + +import ( + "encoding/binary" + "github.com/jhalter/mobius/concat" +) + +const GuestAccount = "guest" // default account used when no login is provided for a connection + +type Account struct { + Login string `yaml:"Login"` + Name string `yaml:"Name"` + Password string `yaml:"Password"` + Access *[]byte `yaml:"Access"` // 8 byte bitmap +} + +// Payload marshals an account to byte slice +// Example: +// 00 04 // fieldCount? +// 00 66 // 102 - fieldUserName +// 00 0d // 13 +// 61 64 6d 69 6e 69 73 74 72 61 74 6f 72 // administrator +// 00 69 // 105 fieldUserLogin (encoded) +// 00 05 // len +// 9e 9b 92 96 91 // encoded login name +// 00 6a // 106 fieldUserPassword +// 00 01 // len +// 78 +// 00 6e // fieldUserAccess +// 00 08 +// ff d3 cf ef ff 80 00 00 +func (a *Account) Payload() (out []byte) { + nameLen := make([]byte, 2) + binary.BigEndian.PutUint16(nameLen, uint16(len(a.Name))) + + loginLen := make([]byte, 2) + binary.BigEndian.PutUint16(loginLen, uint16(len(a.Login))) + + return concat.Slices( + []byte{0x00, 0x3}, // param count -- always 3 + + []byte{0x00, 0x66}, // fieldUserName + nameLen, + []byte(a.Name), + + []byte{0x00, 0x69}, // fieldUserLogin + loginLen, + []byte(NegatedUserString([]byte(a.Login))), + + []byte{0x00, 0x6e}, // fieldUserAccess + []byte{0x00, 0x08}, + *a.Access, + ) +} diff --git a/hotline/banners/1.txt b/hotline/banners/1.txt new file mode 100644 index 0000000..608a316 --- /dev/null +++ b/hotline/banners/1.txt @@ -0,0 +1,6 @@ + __ __ ______ ______ __ __ __ __ ______ +/\ \_\ \ /\ __ \ /\__ _\ /\ \ /\ \ /\ "-.\ \ /\ ___\ +\ \ __ \ \ \ \/\ \ \/_/\ \/ \ \ \____ \ \ \ \ \ \-. \ \ \ __\ + \ \_\ \_\ \ \_____\ \ \_\ \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ + \/_/\/_/ \/_____/ \/_/ \/_____/ \/_/ \/_/ \/_/ \/_____/ + \ No newline at end of file diff --git a/hotline/banners/2.txt b/hotline/banners/2.txt new file mode 100644 index 0000000..1121e06 --- /dev/null +++ b/hotline/banners/2.txt @@ -0,0 +1,9 @@ + ▄█ █▄ ▄██████▄ ███ ▄█ ▄█ ███▄▄▄▄ ▄████████ + ███ ███ ███ ███ ▀█████████▄ ███ ███ ███▀▀▀██▄ ███ ███ + ███ ███ ███ ███ ▀███▀▀██ ███ ███▌ ███ ███ ███ █▀ + ▄███▄▄▄▄███▄▄ ███ ███ ███ ▀ ███ ███▌ ███ ███ ▄███▄▄▄ +▀▀███▀▀▀▀███▀ ███ ███ ███ ███ ███▌ ███ ███ ▀▀███▀▀▀ + ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ █▄ + ███ ███ ███ ███ ███ ███▌ ▄ ███ ███ ███ ███ ███ + ███ █▀ ▀██████▀ ▄████▀ █████▄▄██ █▀ ▀█ █▀ ██████████ + ▀ \ No newline at end of file diff --git a/hotline/banners/3.txt b/hotline/banners/3.txt new file mode 100644 index 0000000..608a316 --- /dev/null +++ b/hotline/banners/3.txt @@ -0,0 +1,6 @@ + __ __ ______ ______ __ __ __ __ ______ +/\ \_\ \ /\ __ \ /\__ _\ /\ \ /\ \ /\ "-.\ \ /\ ___\ +\ \ __ \ \ \ \/\ \ \/_/\ \/ \ \ \____ \ \ \ \ \ \-. \ \ \ __\ + \ \_\ \_\ \ \_____\ \ \_\ \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ + \/_/\/_/ \/_____/ \/_/ \/_____/ \/_/ \/_/ \/_/ \/_____/ + \ No newline at end of file diff --git a/hotline/banners/4.txt b/hotline/banners/4.txt new file mode 100644 index 0000000..3b9d720 --- /dev/null +++ b/hotline/banners/4.txt @@ -0,0 +1,7 @@ +.___.__  ._______  _____._.___    .___ .______  ._______ +:   |  \ : .___  \ \__ _:||   |   : __|:      \ : .____/ +|   :   || :   |  |  |  :||   |   | : ||       || : _/\  +|   .   ||     :  |  |   ||   |/\ |   ||   |   ||   /  \ +|___|   | \_. ___/   |   ||   /  \|   ||___|   ||_.: __/ +    |___|   :/       |___||______/|___|    |___|   :/    +    :                                        diff --git a/hotline/banners/5.txt b/hotline/banners/5.txt new file mode 100644 index 0000000..b6654e7 --- /dev/null +++ b/hotline/banners/5.txt @@ -0,0 +1,7 @@ + ▄▀▀▄ ▄▄ ▄▀▀▀▀▄ ▄▀▀▀█▀▀▄ ▄▀▀▀▀▄ ▄▀▀█▀▄ ▄▀▀▄ ▀▄ ▄▀▀█▄▄▄▄ +█ █ ▄▀ █ █ █ █ ▐ █ █ █ █ █ █ █ █ █ ▐ ▄▀ ▐ +▐ █▄▄▄█ █ █ ▐ █ ▐ █ ▐ █ ▐ ▐ █ ▀█ █▄▄▄▄▄ + █ █ ▀▄ ▄▀ █ █ █ █ █ █ ▌ + ▄▀ ▄▀ ▀▀▀▀ ▄▀ ▄▀▄▄▄▄▄▄▀ ▄▀▀▀▀▀▄ ▄▀ █ ▄▀▄▄▄▄ + █ █ █ █ █ █ █ ▐ █ ▐ + ▐ ▐ ▐ ▐ ▐ ▐ ▐ ▐ \ No newline at end of file diff --git a/hotline/banners/6.txt b/hotline/banners/6.txt new file mode 100644 index 0000000..c8ac7a6 --- /dev/null +++ b/hotline/banners/6.txt @@ -0,0 +1,6 @@ + :: .: ... :::::::::::: ::: ::::::. :::..,:::::: + ,;; ;;, .;;;;;;;. ;;;;;;;;'''' ;;; ;;;`;;;;, `;;;;;;;'''' +,[[[,,,[[[ ,[[ \[[, [[ [[[ [[[ [[[[[. '[[ [[cccc +"$$$"""$$$ $$$, $$$ $$ $$' $$$ $$$ "Y$c$$ $$"""" + 888 "88o"888,_ _,88P 88, o88oo,.__888 888 Y88 888oo,__ + MMM YMM "YMMMMMP" MMM """"YUMMMMMM MMM YM """"YUMMM \ No newline at end of file diff --git a/hotline/banners/7.txt b/hotline/banners/7.txt new file mode 100644 index 0000000..c072e25 --- /dev/null +++ b/hotline/banners/7.txt @@ -0,0 +1,7 @@ +██╗ ██╗ ██████╗ ████████╗██╗ ██╗███╗ ██╗███████╗ +██║ ██║██╔═══██╗╚══██╔══╝██║ ██║████╗ ██║██╔════╝ +███████║██║ ██║ ██║ ██║ ██║██╔██╗ ██║█████╗ +██╔══██║██║ ██║ ██║ ██║ ██║██║╚██╗██║██╔══╝ +██║ ██║╚██████╔╝ ██║ ███████╗██║██║ ╚████║███████╗ +╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝ + diff --git a/hotline/banners/8.txt b/hotline/banners/8.txt new file mode 100644 index 0000000..d75e153 --- /dev/null +++ b/hotline/banners/8.txt @@ -0,0 +1,10 @@ + ██░ ██ ▒█████ ▄▄▄█████▓ ██▓ ██▓ ███▄ █ ▓█████ +▓██░ ██▒▒██▒ ██▒▓ ██▒ ▓▒▓██▒ ▓██▒ ██ ▀█ █ ▓█ ▀ +▒██▀▀██░▒██░ ██▒▒ ▓██░ ▒░▒██░ ▒██▒▓██ ▀█ ██▒▒███ +░▓█ ░██ ▒██ ██░░ ▓██▓ ░ ▒██░ ░██░▓██▒ ▐▌██▒▒▓█ ▄ +░▓█▒░██▓░ ████▓▒░ ▒██▒ ░ ░██████▒░██░▒██░ ▓██░░▒████▒ + ▒ ░░▒░▒░ ▒░▒░▒░ ▒ ░░ ░ ▒░▓ ░░▓ ░ ▒░ ▒ ▒ ░░ ▒░ ░ + ▒ ░▒░ ░ ░ ▒ ▒░ ░ ░ ░ ▒ ░ ▒ ░░ ░░ ░ ▒░ ░ ░ ░ + ░ ░░ ░░ ░ ░ ▒ ░ ░ ░ ▒ ░ ░ ░ ░ ░ + ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ + diff --git a/hotline/banners/9.txt b/hotline/banners/9.txt new file mode 100644 index 0000000..eba9134 --- /dev/null +++ b/hotline/banners/9.txt @@ -0,0 +1,9 @@ + █████ █████ █████ ████ ███ +░░███ ░░███ ░░███ ░░███ ░░░ + ░███ ░███ ██████ ███████ ░███ ████ ████████ ██████ + ░███████████ ███░░███░░░███░ ░███ ░░███ ░░███░░███ ███░░███ + ░███░░░░░███ ░███ ░███ ░███ ░███ ░███ ░███ ░███ ░███████ + ░███ ░███ ░███ ░███ ░███ ███ ░███ ░███ ░███ ░███ ░███░░░ + █████ █████░░██████ ░░█████ █████ █████ ████ █████░░██████ +░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░ ░░░░░ ░░░░░░ + diff --git a/hotline/client.go b/hotline/client.go new file mode 100644 index 0000000..9bb33e1 --- /dev/null +++ b/hotline/client.go @@ -0,0 +1,1010 @@ +package hotline + +import ( + "bytes" + "embed" + "encoding/binary" + "errors" + "fmt" + "github.com/davecgh/go-spew/spew" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/stretchr/testify/mock" + "go.uber.org/zap" + "gopkg.in/yaml.v2" + "io/ioutil" + "math/big" + "math/rand" + "net" + "os" + "strconv" + "strings" + "time" +) + +const clientConfigPath = "/usr/local/etc/mobius-client-config.yaml" +const ( + trackerListPage = "trackerList" +) + +//go:embed banners/*.txt +var bannerDir embed.FS + +type Bookmark struct { + Name string `yaml:"Name"` + Addr string `yaml:"Addr"` + Login string `yaml:"Login"` + Password string `yaml:"Password"` +} + +type ClientPrefs struct { + Username string `yaml:"Username"` + IconID int `yaml:"IconID"` + Bookmarks []Bookmark `yaml:"Bookmarks"` + Tracker string `yaml:"Tracker"` +} + +func (cp *ClientPrefs) IconBytes() []byte { + iconBytes := make([]byte, 2) + binary.BigEndian.PutUint16(iconBytes, uint16(cp.IconID)) + return iconBytes +} + +func readConfig(cfgPath string) (*ClientPrefs, error) { + fh, err := os.Open(cfgPath) + if err != nil { + return nil, err + } + + prefs := ClientPrefs{} + decoder := yaml.NewDecoder(fh) + decoder.SetStrict(true) + if err := decoder.Decode(&prefs); err != nil { + return nil, err + } + return &prefs, nil +} + +type Client struct { + DebugBuf *DebugBuffer + Connection net.Conn + Login *[]byte + Password *[]byte + Flags *[]byte + ID *[]byte + Version []byte + UserAccess []byte + Agreed bool + UserList []User + Logger *zap.SugaredLogger + activeTasks map[uint32]*Transaction + + pref *ClientPrefs + + Handlers map[uint16]clientTHandler + + UI *UI + + outbox chan *Transaction + Inbox chan *Transaction +} + +type UI struct { + chatBox *tview.TextView + chatInput *tview.InputField + App *tview.Application + Pages *tview.Pages + userList *tview.TextView + agreeModal *tview.Modal + trackerList *tview.List + settingsPage *tview.Box + HLClient *Client +} + +func NewUI(c *Client) *UI { + app := tview.NewApplication() + chatBox := tview.NewTextView(). + SetScrollable(true). + SetDynamicColors(true). + SetWordWrap(true). + SetChangedFunc(func() { + app.Draw() // TODO: docs say this is bad but it's the only way to show content during initial render?? + }) + chatBox.Box.SetBorder(true).SetTitle("Chat") + + chatInput := tview.NewInputField() + chatInput. + SetLabel("> "). + SetFieldBackgroundColor(tcell.ColorDimGray). + SetDoneFunc(func(key tcell.Key) { + // skip send if user hit enter with no other text + if len(chatInput.GetText()) == 0 { + return + } + + c.Send( + *NewTransaction(tranChatSend, nil, + NewField(fieldData, []byte(chatInput.GetText())), + ), + ) + chatInput.SetText("") // clear the input field after chat send + }) + + chatInput.Box.SetBorder(true).SetTitle("Send") + + userList := tview. + NewTextView(). + SetDynamicColors(true). + SetChangedFunc(func() { + app.Draw() // TODO: docs say this is bad but it's the only way to show content during initial render?? + }) + userList.Box.SetBorder(true).SetTitle("Users") + + return &UI{ + App: app, + chatBox: chatBox, + Pages: tview.NewPages(), + chatInput: chatInput, + userList: userList, + trackerList: tview.NewList(), + agreeModal: tview.NewModal(), + HLClient: c, + } +} + +func (ui *UI) showBookmarks() *tview.List { + list := tview.NewList() + list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEsc { + ui.Pages.SwitchToPage("home") + } + return event + }) + list.Box.SetBorder(true).SetTitle("| Bookmarks |") + + shortcut := 97 // rune for "a" + for i, srv := range ui.HLClient.pref.Bookmarks { + addr := srv.Addr + login := srv.Login + pass := srv.Password + list.AddItem(srv.Name, srv.Addr, rune(shortcut+i), func() { + ui.Pages.RemovePage("joinServer") + + newJS := ui.renderJoinServerForm(addr, login, pass, "bookmarks", true, true) + + ui.Pages.AddPage("joinServer", newJS, true, true) + }) + } + + return list +} + +func (ui *UI) getTrackerList() *tview.List { + listing, err := GetListing(ui.HLClient.pref.Tracker) + if err != nil { + spew.Dump(err) + } + + list := tview.NewList() + list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEsc { + ui.Pages.SwitchToPage("home") + } + return event + }) + list.Box.SetBorder(true).SetTitle("| Servers |") + + shortcut := 97 // rune for "a" + for i, srv := range listing { + addr := srv.Addr() + list.AddItem(string(srv.Name), string(srv.Description), rune(shortcut+i), func() { + ui.Pages.RemovePage("joinServer") + + newJS := ui.renderJoinServerForm(addr, GuestAccount, "", trackerListPage, false, true) + + ui.Pages.AddPage("joinServer", newJS, true, true) + ui.Pages.ShowPage("joinServer") + }) + } + + return list +} + +func (ui *UI) renderSettingsForm() *tview.Flex { + iconStr := strconv.Itoa(ui.HLClient.pref.IconID) + settingsForm := tview.NewForm() + settingsForm.AddInputField("Your Name", ui.HLClient.pref.Username, 0, nil, nil) + settingsForm.AddInputField("IconID", iconStr, 0, func(idStr string, _ rune) bool { + _, err := strconv.Atoi(idStr) + return err == nil + }, nil) + settingsForm.AddInputField("Tracker", ui.HLClient.pref.Tracker, 0, nil, nil) + settingsForm.AddButton("Save", func() { + usernameInput := settingsForm.GetFormItem(0).(*tview.InputField).GetText() + if len(usernameInput) == 0 { + usernameInput = "unnamed" + } + ui.HLClient.pref.Username = usernameInput + iconStr = settingsForm.GetFormItem(1).(*tview.InputField).GetText() + ui.HLClient.pref.IconID, _ = strconv.Atoi(iconStr) + ui.HLClient.pref.Tracker = settingsForm.GetFormItem(2).(*tview.InputField).GetText() + + out, err := yaml.Marshal(&ui.HLClient.pref) + if err != nil { + // TODO: handle err + } + // TODO: handle err + _ = ioutil.WriteFile(clientConfigPath, out, 0666) + ui.Pages.RemovePage("settings") + }) + settingsForm.SetBorder(true) + settingsForm.SetCancelFunc(func() { + ui.Pages.RemovePage("settings") + }) + settingsPage := tview.NewFlex().SetDirection(tview.FlexRow) + settingsPage.Box.SetBorder(true).SetTitle("Settings") + settingsPage.AddItem(settingsForm, 0, 1, true) + + centerFlex := tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(settingsForm, 15, 1, true). + AddItem(nil, 0, 1, false), 40, 1, true). + AddItem(nil, 0, 1, false) + + return centerFlex +} + +// DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger +type DebugBuffer struct { + TextView *tview.TextView +} + +func (db *DebugBuffer) Write(p []byte) (int, error) { + return db.TextView.Write(p) +} + +// Sync is a noop function that exists to satisfy the zapcore.WriteSyncer interface +func (db *DebugBuffer) Sync() error { + return nil +} + +func (ui *UI) joinServer(addr, login, password string) error { + if err := ui.HLClient.JoinServer(addr, login, password); err != nil { + return errors.New(fmt.Sprintf("Error joining server: %v\n", err)) + } + + go func() { + err := ui.HLClient.ReadLoop() + if err != nil { + ui.HLClient.Logger.Errorw("read error", "err", err) + } + }() + return nil +} + +func (ui *UI) renderJoinServerForm(server, login, password, backPage string, save, defaultConnect bool) *tview.Flex { + joinServerForm := tview.NewForm() + joinServerForm. + AddInputField("Server", server, 0, nil, nil). + AddInputField("Login", login, 0, nil, nil). + AddPasswordField("Password", password, 0, '*', nil). + AddCheckbox("Save", save, func(checked bool) { + // TODO: Implement bookmark saving + }). + AddButton("Cancel", func() { + ui.Pages.SwitchToPage(backPage) + }). + AddButton("Connect", func() { + err := ui.joinServer( + joinServerForm.GetFormItem(0).(*tview.InputField).GetText(), + joinServerForm.GetFormItem(1).(*tview.InputField).GetText(), + joinServerForm.GetFormItem(2).(*tview.InputField).GetText(), + ) + if err != nil { + ui.HLClient.Logger.Errorw("login error", "err", err) + loginErrModal := tview.NewModal(). + AddButtons([]string{"Oh no"}). + SetText(err.Error()). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + ui.Pages.SwitchToPage(backPage) + }) + + ui.Pages.AddPage("loginErr", loginErrModal, false, true) + } + + // Save checkbox + if joinServerForm.GetFormItem(3).(*tview.Checkbox).IsChecked() { + // TODO: implement bookmark saving + } + }) + + joinServerForm.Box.SetBorder(true).SetTitle("| Connect |") + joinServerForm.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + ui.Pages.SwitchToPage(backPage) + } + return event + }) + + if defaultConnect { + joinServerForm.SetFocus(5) + } + + joinServerPage := tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(joinServerForm, 14, 1, true). + AddItem(nil, 0, 1, false), 40, 1, true). + AddItem(nil, 0, 1, false) + + return joinServerPage +} + +func randomBanner() string { + rand.Seed(time.Now().UnixNano()) + + bannerFiles, _ := bannerDir.ReadDir("client/banners") + file, _ := bannerDir.ReadFile("client/banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name()) + + return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file) +} + +func (ui *UI) renderServerUI() *tview.Flex { + commandList := tview.NewTextView().SetDynamicColors(true) + commandList. + SetText("[yellow]^n[-::]: Read News [yellow]^p[-::]: Post News\n[yellow]^l[-::]: View Logs\n"). + SetBorder(true). + SetTitle("Keyboard Shortcuts") + + modal := tview.NewModal(). + SetText("Disconnect from the server?"). + AddButtons([]string{"Cancel", "Exit"}). + SetFocus(1) + modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) { + if buttonIndex == 1 { + _ = ui.HLClient.Disconnect() + ui.Pages.SwitchToPage("home") + } else { + ui.Pages.HidePage("modal") + } + }) + + serverUI := tview.NewFlex(). + AddItem(tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(commandList, 4, 0, false). + AddItem(ui.chatBox, 0, 8, false). + AddItem(ui.chatInput, 3, 0, true), 0, 1, true). + AddItem(ui.userList, 25, 1, false) + serverUI.SetBorder(true).SetTitle("| Mobius - Connected to " + "TODO" + " |").SetTitleAlign(tview.AlignLeft) + serverUI.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEscape { + ui.Pages.AddPage("modal", modal, false, true) + } + + // Show News + if event.Key() == tcell.KeyCtrlN { + if err := ui.HLClient.Send(*NewTransaction(tranGetMsgs, nil)); err != nil { + ui.HLClient.Logger.Errorw("err", "err", err) + } + } + + // Post news + if event.Key() == tcell.KeyCtrlP { + + newsFlex := tview.NewFlex() + + newsPostTextArea := tview.NewTextView() + newsPostTextArea.SetBackgroundColor(tcell.ColorDimGray) + newsPostTextArea.SetChangedFunc(func() { + ui.App.Draw() // TODO: docs say this is bad but it's the only way to show content during initial render?? + }) + //newsPostTextArea.SetBorderPadding(0, 0, 1, 1) + + newsPostForm := tview.NewForm(). + SetButtonsAlign(tview.AlignRight). + AddButton("Post", nil) + newsPostForm.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyTab: + ui.App.SetFocus(newsPostTextArea) + case tcell.KeyEnter: + newsText := strings.ReplaceAll(newsPostTextArea.GetText(true), "\n", "\r") + err := ui.HLClient.Send( + *NewTransaction(tranOldPostNews, nil, + NewField(fieldData, []byte(newsText)), + ), + ) + if err != nil { + ui.HLClient.Logger.Errorw("Error posting news", "err", err) + // TODO: display errModal to user + } + //newsInput.SetText("") // clear the input field after chat send + ui.Pages.RemovePage("newsInput") + } + + return event + }) + + newsFlex. + SetDirection(tview.FlexRow). + SetBorder(true). + SetTitle("News Post") + + newsPostTextArea.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + ui.HLClient.Logger.Infow("key", "key", event.Key(), "rune", event.Rune()) + switch event.Key() { + case tcell.KeyEscape: + ui.Pages.RemovePage("newsInput") + case tcell.KeyTab: + ui.App.SetFocus(newsPostForm) + case tcell.KeyEnter: + fmt.Fprintf(newsPostTextArea, "\n") + default: + switch event.Rune() { + case 127: // backspace + curTxt := newsPostTextArea.GetText(true) + if len(curTxt) > 0 { + curTxt = curTxt[:len(curTxt)-1] + newsPostTextArea.SetText(curTxt) + } + default: + fmt.Fprintf(newsPostTextArea, string(event.Rune())) + } + } + + return event + }) + + newsFlex.AddItem(newsPostTextArea, 10, 0, true) + newsFlex.AddItem(newsPostForm, 3, 0, false) + + newsPostPage := tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(newsFlex, 15, 1, true). + //AddItem(newsPostForm, 3, 0, false). + AddItem(nil, 0, 1, false), 40, 1, false). + AddItem(nil, 0, 1, false) + + ui.Pages.AddPage("newsInput", newsPostPage, true, true) + ui.App.SetFocus(newsPostTextArea) + } + + return event + }) + return serverUI +} + +func (ui *UI) Start() { + home := tview.NewFlex().SetDirection(tview.FlexRow) + home.Box.SetBorder(true).SetTitle("| Mobius v" + VERSION + " |").SetTitleAlign(tview.AlignLeft) + mainMenu := tview.NewList() + + bannerItem := tview.NewTextView(). + SetText(randomBanner()). + SetDynamicColors(true). + SetTextAlign(tview.AlignCenter) + + home.AddItem( + tview.NewFlex().AddItem(bannerItem, 0, 1, false), + 14, 1, false) + home.AddItem(tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(mainMenu, 0, 1, true). + AddItem(nil, 0, 1, false), + 0, 1, true, + ) + + mainMenu.AddItem("Join Server", "", 'j', func() { + joinServerPage := ui.renderJoinServerForm("", GuestAccount, "", "home", false, false) + ui.Pages.AddPage("joinServer", joinServerPage, true, true) + }). + AddItem("Bookmarks", "", 'b', func() { + ui.Pages.AddAndSwitchToPage("bookmarks", ui.showBookmarks(), true) + }). + AddItem("Browse Tracker", "", 't', func() { + ui.trackerList = ui.getTrackerList() + ui.Pages.AddAndSwitchToPage("trackerList", ui.trackerList, true) + }). + AddItem("Settings", "", 's', func() { + ui.Pages.AddPage("settings", ui.renderSettingsForm(), true, true) + }). + AddItem("Quit", "", 'q', func() { + ui.App.Stop() + }) + + ui.Pages.AddPage("home", home, true, true) + + // App level input capture + ui.App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyCtrlC { + ui.HLClient.Logger.Infow("Exiting") + ui.App.Stop() + os.Exit(0) + } + // Show Logs + if event.Key() == tcell.KeyCtrlL { + ui.HLClient.DebugBuf.TextView.ScrollToEnd() + ui.HLClient.DebugBuf.TextView.SetBorder(true).SetTitle("Logs") + ui.HLClient.DebugBuf.TextView.SetDoneFunc(func(key tcell.Key) { + if key == tcell.KeyEscape { + ui.Pages.RemovePage("logs") + } + }) + + ui.Pages.AddAndSwitchToPage("logs", ui.HLClient.DebugBuf.TextView, true) + } + return event + }) + + if err := ui.App.SetRoot(ui.Pages, true).SetFocus(ui.Pages).Run(); err != nil { + ui.App.Stop() + os.Exit(1) + } +} + +func NewClient(username string, logger *zap.SugaredLogger) *Client { + c := &Client{ + Logger: logger, + activeTasks: make(map[uint32]*Transaction), + Handlers: clientHandlers, + } + c.UI = NewUI(c) + + prefs, err := readConfig(clientConfigPath) + if err != nil { + return c + } + c.pref = prefs + + return c +} + +type clientTransaction struct { + Name string + Handler func(*Client, *Transaction) ([]Transaction, error) +} + +func (ch clientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) { + return ch.Handler(cc, t) +} + +type clientTHandler interface { + Handle(*Client, *Transaction) ([]Transaction, error) +} + +type mockClientHandler struct { + mock.Mock +} + +func (mh *mockClientHandler) Handle(cc *Client, t *Transaction) ([]Transaction, error) { + args := mh.Called(cc, t) + return args.Get(0).([]Transaction), args.Error(1) +} + +var clientHandlers = map[uint16]clientTHandler{ + // Server initiated + tranChatMsg: clientTransaction{ + Name: "tranChatMsg", + Handler: handleClientChatMsg, + }, + tranLogin: clientTransaction{ + Name: "tranLogin", + Handler: handleClientTranLogin, + }, + tranShowAgreement: clientTransaction{ + Name: "tranShowAgreement", + Handler: handleClientTranShowAgreement, + }, + tranUserAccess: clientTransaction{ + Name: "tranUserAccess", + Handler: handleClientTranUserAccess, + }, + tranGetUserNameList: clientTransaction{ + Name: "tranGetUserNameList", + Handler: handleClientGetUserNameList, + }, + tranNotifyChangeUser: clientTransaction{ + Name: "tranNotifyChangeUser", + Handler: handleNotifyChangeUser, + }, + tranNotifyDeleteUser: clientTransaction{ + Name: "tranNotifyDeleteUser", + Handler: handleNotifyDeleteUser, + }, + tranGetMsgs: clientTransaction{ + Name: "tranNotifyDeleteUser", + Handler: handleGetMsgs, + }, +} + +func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) { + newsText := string(t.GetField(fieldData).Data) + newsText = strings.ReplaceAll(newsText, "\r", "\n") + + newsTextView := tview.NewTextView(). + SetText(newsText). + SetDoneFunc(func(key tcell.Key) { + c.UI.Pages.SwitchToPage("serverUI") + c.UI.App.SetFocus(c.UI.chatInput) + }) + newsTextView.SetBorder(true).SetTitle("News") + + c.UI.Pages.AddPage("news", newsTextView, true, true) + c.UI.Pages.SwitchToPage("news") + c.UI.App.SetFocus(newsTextView) + + c.UI.App.Draw() + + return res, err +} + +func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) { + newUser := User{ + ID: t.GetField(fieldUserID).Data, + Name: string(t.GetField(fieldUserName).Data), + Icon: t.GetField(fieldUserIconID).Data, + Flags: t.GetField(fieldUserFlags).Data, + } + + // Possible cases: + // user is new to the server + // user is already on the server but has a new name + + var oldName string + var newUserList []User + updatedUser := false + for _, u := range c.UserList { + c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name) + if bytes.Equal(newUser.ID, u.ID) { + oldName = u.Name + u.Name = newUser.Name + if u.Name != newUser.Name { + _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n") + } + updatedUser = true + } + newUserList = append(newUserList, u) + } + + if !updatedUser { + newUserList = append(newUserList, newUser) + } + + c.UserList = newUserList + + c.renderUserList() + + return res, err +} + +func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) { + exitUser := t.GetField(fieldUserID).Data + + var newUserList []User + for _, u := range c.UserList { + if !bytes.Equal(exitUser, u.ID) { + newUserList = append(newUserList, u) + } + } + + c.UserList = newUserList + + c.renderUserList() + + return res, err +} + +const readBuffSize = 1024000 // 1KB - TODO: what should this be? + +func (c *Client) ReadLoop() error { + tranBuff := make([]byte, 0) + tReadlen := 0 + // Infinite loop where take action on incoming client requests until the connection is closed + for { + buf := make([]byte, readBuffSize) + tranBuff = tranBuff[tReadlen:] + + readLen, err := c.Connection.Read(buf) + if err != nil { + return err + } + tranBuff = append(tranBuff, buf[:readLen]...) + + // We may have read multiple requests worth of bytes from Connection.Read. readTransactions splits them + // into a slice of transactions + var transactions []Transaction + if transactions, tReadlen, err = readTransactions(tranBuff); err != nil { + c.Logger.Errorw("Error handling transaction", "err", err) + } + + // iterate over all of the transactions that were parsed from the byte slice and handle them + for _, t := range transactions { + if err := c.HandleTransaction(&t); err != nil { + c.Logger.Errorw("Error handling transaction", "err", err) + } + } + } +} + +func (c *Client) GetTransactions() error { + tranBuff := make([]byte, 0) + tReadlen := 0 + + buf := make([]byte, readBuffSize) + tranBuff = tranBuff[tReadlen:] + + readLen, err := c.Connection.Read(buf) + if err != nil { + return err + } + tranBuff = append(tranBuff, buf[:readLen]...) + + return nil +} + +func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) { + var users []User + for _, field := range t.Fields { + // The Hotline protocol docs say that ClientGetUserNameList should only return fieldUsernameWithInfo (300) + // fields, but shxd sneaks in fieldChatSubject (115) so it's important to filter explicitly for the expected + // field type. Probably a good idea to do everywhere. + if bytes.Equal(field.ID, []byte{0x01, 0x2c}) { + u, err := ReadUser(field.Data) + if err != nil { + return res, err + } + users = append(users, *u) + } + } + c.UserList = users + + c.renderUserList() + + return res, err +} + +func (c *Client) renderUserList() { + c.UI.userList.Clear() + for _, u := range c.UserList { + flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags))) + if flagBitmap.Bit(userFlagAdmin) == 1 { + _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name) + } else { + _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name) + } + } +} + +func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) { + _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(fieldData).Data) + + return res, err +} + +func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) { + c.UserAccess = t.GetField(fieldUserAccess).Data + + return res, err +} + +func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) { + agreement := string(t.GetField(fieldData).Data) + agreement = strings.ReplaceAll(agreement, "\r", "\n") + + c.UI.agreeModal = tview.NewModal(). + SetText(agreement). + AddButtons([]string{"Agree", "Disagree"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + if buttonIndex == 0 { + res = append(res, + *NewTransaction( + tranAgreed, nil, + NewField(fieldUserName, []byte(c.pref.Username)), + NewField(fieldUserIconID, c.pref.IconBytes()), + NewField(fieldUserFlags, []byte{0x00, 0x00}), + NewField(fieldOptions, []byte{0x00, 0x00}), + ), + ) + c.Agreed = true + c.UI.Pages.HidePage("agreement") + c.UI.App.SetFocus(c.UI.chatInput) + } else { + _ = c.Disconnect() + c.UI.Pages.SwitchToPage("home") + } + }, + ) + + c.Logger.Debug("show agreement page") + c.UI.Pages.AddPage("agreement", c.UI.agreeModal, false, true) + + c.UI.Pages.ShowPage("agreement ") + + c.UI.App.Draw() + return res, err +} + +func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) { + if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) { + errMsg := string(t.GetField(fieldError).Data) + errModal := tview.NewModal() + errModal.SetText(errMsg) + errModal.AddButtons([]string{"Oh no"}) + errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) { + c.UI.Pages.RemovePage("errModal") + }) + c.UI.Pages.RemovePage("joinServer") + c.UI.Pages.AddPage("errModal", errModal, false, true) + + c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf? + + c.Logger.Error(string(t.GetField(fieldError).Data)) + return nil, errors.New("login error: " + string(t.GetField(fieldError).Data)) + } + c.UI.Pages.AddAndSwitchToPage("serverUI", c.UI.renderServerUI(), true) + c.UI.App.SetFocus(c.UI.chatInput) + + if err := c.Send(*NewTransaction(tranGetUserNameList, nil)); err != nil { + c.Logger.Errorw("err", "err", err) + } + return res, err +} + +// JoinServer connects to a Hotline server and completes the login flow +func (c *Client) JoinServer(address, login, passwd string) error { + // Establish TCP connection to server + if err := c.connect(address); err != nil { + return err + } + + // Send handshake sequence + if err := c.Handshake(); err != nil { + return err + } + + // Authenticate (send tranLogin 107) + if err := c.LogIn(login, passwd); err != nil { + return err + } + + return nil +} + +// connect establishes a connection with a Server by sending handshake sequence +func (c *Client) connect(address string) error { + var err error + c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second) + if err != nil { + return err + } + return nil +} + +var ClientHandshake = []byte{ + 0x54, 0x52, 0x54, 0x50, // TRTP + 0x48, 0x4f, 0x54, 0x4c, // HOTL + 0x00, 0x01, + 0x00, 0x02, +} + +var ServerHandshake = []byte{ + 0x54, 0x52, 0x54, 0x50, // TRTP + 0x00, 0x00, 0x00, 0x00, // ErrorCode +} + +func (c *Client) Handshake() error { + //Protocol ID 4 ‘TRTP’ 0x54 52 54 50 + //Sub-protocol ID 4 User defined + //Version 2 1 Currently 1 + //Sub-version 2 User defined + if _, err := c.Connection.Write(ClientHandshake); err != nil { + return fmt.Errorf("handshake write err: %s", err) + } + + replyBuf := make([]byte, 8) + _, err := c.Connection.Read(replyBuf) + if err != nil { + return err + } + + //spew.Dump(replyBuf) + if bytes.Compare(replyBuf, ServerHandshake) == 0 { + return nil + } + // In the case of an error, client and server close the connection. + + return fmt.Errorf("handshake response err: %s", err) +} + +func (c *Client) LogIn(login string, password string) error { + return c.Send( + *NewTransaction( + tranLogin, nil, + NewField(fieldUserName, []byte(c.pref.Username)), + NewField(fieldUserIconID, c.pref.IconBytes()), + NewField(fieldUserLogin, []byte(NegatedUserString([]byte(login)))), + NewField(fieldUserPassword, []byte(NegatedUserString([]byte(password)))), + NewField(fieldVersion, []byte{0, 2}), + ), + ) +} + +func (c *Client) Send(t Transaction) error { + requestNum := binary.BigEndian.Uint16(t.Type) + tID := binary.BigEndian.Uint32(t.ID) + + //handler := TransactionHandlers[requestNum] + + // if transaction is NOT reply, add it to the list to transactions we're expecting a response for + if t.IsReply == 0 { + c.activeTasks[tID] = &t + } + + var n int + var err error + if n, err = c.Connection.Write(t.Payload()); err != nil { + return err + } + c.Logger.Debugw("Sent Transaction", + "IsReply", t.IsReply, + "type", requestNum, + "sentBytes", n, + ) + return nil +} + +func (c *Client) HandleTransaction(t *Transaction) error { + var origT Transaction + if t.IsReply == 1 { + requestID := binary.BigEndian.Uint32(t.ID) + origT = *c.activeTasks[requestID] + t.Type = origT.Type + } + + requestNum := binary.BigEndian.Uint16(t.Type) + c.Logger.Infow( + "Received Transaction", + "RequestType", requestNum, + ) + + if handler, ok := c.Handlers[requestNum]; ok { + outT, _ := handler.Handle(c, t) + for _, t := range outT { + c.Send(t) + } + } else { + c.Logger.Errorw( + "Unimplemented transaction type received", + "RequestID", requestNum, + "TransactionID", t.ID, + ) + } + + return nil +} + +func (c *Client) Connected() bool { + fmt.Printf("Agreed: %v UserAccess: %v\n", c.Agreed, c.UserAccess) + // c.Agreed == true && + if c.UserAccess != nil { + return true + } + return false +} + +func (c *Client) Disconnect() error { + err := c.Connection.Close() + if err != nil { + return err + } + return nil +} diff --git a/hotline/client_conn.go b/hotline/client_conn.go new file mode 100644 index 0000000..5c25f92 --- /dev/null +++ b/hotline/client_conn.go @@ -0,0 +1,247 @@ +package hotline + +import ( + "bytes" + "encoding/binary" + "errors" + "golang.org/x/crypto/bcrypt" + "math/big" + "net" +) + +type byClientID []*ClientConn + +func (s byClientID) Len() int { + return len(s) +} + +func (s byClientID) Swap(i, j int) { + s[i], s[j] = s[j], s[i] +} + +func (s byClientID) Less(i, j int) bool { + return s[i].uint16ID() < s[j].uint16ID() +} + +// ClientConn represents a client connected to a Server +type ClientConn struct { + Connection net.Conn + ID *[]byte + Icon *[]byte + Flags *[]byte + UserName *[]byte + Account *Account + IdleTime *int + Server *Server + Version *[]byte + Idle bool + AutoReply *[]byte + Transfers map[int][]*FileTransfer +} + +func (cc *ClientConn) sendAll(t int, fields ...Field) { + for _, c := range sortedClients(cc.Server.Clients) { + cc.Server.outbox <- *NewTransaction(t, c.ID, fields...) + } +} + +func (cc *ClientConn) handleTransaction(transaction *Transaction) error { + requestNum := binary.BigEndian.Uint16(transaction.Type) + if handler, ok := TransactionHandlers[requestNum]; ok { + for _, reqField := range handler.RequiredFields { + field := transaction.GetField(reqField.ID) + + // Validate that required field is present + if field.ID == nil { + cc.Server.Logger.Infow( + "Missing required field", + "Account", cc.Account.Login, "UserName", string(*cc.UserName), "RequestType", handler.Name, "FieldID", reqField.ID, + ) + return nil + } + + if len(field.Data) < reqField.minLen { + cc.Server.Logger.Infow( + "Field does not meet minLen", + "Account", cc.Account.Login, "UserName", string(*cc.UserName), "RequestType", handler.Name, "FieldID", reqField.ID, + ) + return nil + } + } + if !authorize(cc.Account.Access, handler.Access) { + cc.Server.Logger.Infow( + "Unauthorized Action", + "Account", cc.Account.Login, "UserName", string(*cc.UserName), "RequestType", handler.Name, + ) + cc.Server.outbox <- cc.NewErrReply(transaction, handler.DenyMsg) + + return nil + } + + cc.Server.Logger.Infow( + "Received Transaction", + "login", cc.Account.Login, + "name", string(*cc.UserName), + "RequestType", handler.Name, + ) + + transactions, err := handler.Handler(cc, transaction) + if err != nil { + return err + } + for _, t := range transactions { + cc.Server.outbox <- t + } + } else { + cc.Server.Logger.Errorw( + "Unimplemented transaction type received", + "UserName", string(*cc.UserName), "RequestID", requestNum, + ) + } + + cc.Server.mux.Lock() + defer cc.Server.mux.Unlock() + + // if user was idle and this is a non-keepalive transaction + if *cc.IdleTime > userIdleSeconds && requestNum != tranKeepAlive { + flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(*cc.Flags))) + flagBitmap.SetBit(flagBitmap, userFlagAway, 0) + binary.BigEndian.PutUint16(*cc.Flags, uint16(flagBitmap.Int64())) + cc.Idle = false + //*cc.IdleTime = 0 + + cc.sendAll( + tranNotifyChangeUser, + NewField(fieldUserID, *cc.ID), + NewField(fieldUserFlags, *cc.Flags), + NewField(fieldUserName, *cc.UserName), + NewField(fieldUserIconID, *cc.Icon), + ) + + //return nil + } + + // TODO: Don't we need to skip this if requestNum == tranKeepalive ?? + *cc.IdleTime = 0 + + return nil +} + +func (cc *ClientConn) Authenticate(login string, password []byte) bool { + if account, ok := cc.Server.Accounts[login]; ok { + return bcrypt.CompareHashAndPassword([]byte(account.Password), password) == nil + } + + return false +} + +func (cc *ClientConn) uint16ID() uint16 { + id, _ := byteToInt(*cc.ID) + return uint16(id) +} + +// Authorize checks if the user account has the specified permission +func (cc *ClientConn) Authorize(access int) bool { + if access == 0 { + return true + } + + accessBitmap := big.NewInt(int64(binary.BigEndian.Uint64(*cc.Account.Access))) + + return accessBitmap.Bit(63-access) == 1 +} + +// Disconnect notifies other clients that a client has disconnected +func (cc ClientConn) Disconnect() { + cc.Server.mux.Lock() + defer cc.Server.mux.Unlock() + + delete(cc.Server.Clients, binary.BigEndian.Uint16(*cc.ID)) + + cc.NotifyOthers(*NewTransaction(tranNotifyDeleteUser, nil, NewField(fieldUserID, *cc.ID))) + + if err := cc.Connection.Close(); err != nil { + cc.Server.Logger.Errorw("error closing client connection", "RemoteAddr", cc.Connection.RemoteAddr()) + } +} + +// NotifyOthers sends transaction t to other clients connected to the server +func (cc ClientConn) NotifyOthers(t Transaction) { + for _, c := range sortedClients(cc.Server.Clients) { + if c.ID != cc.ID { + t.clientID = c.ID + cc.Server.outbox <- t + } + } +} + +type handshake struct { + Protocol [4]byte // Must be 0x54525450 TRTP + SubProtocol [4]byte + Version [2]byte // Always 1 + SubVersion [2]byte +} + +// Handshake +// After establishing TCP connection, both client and server start the handshake process +// in order to confirm that each of them comply with requirements of the other. +// The information provided in this initial data exchange identifies protocols, +// and their versions, used in the communication. In the case where, after inspection, +// the capabilities of one of the subjects do not comply with the requirements of the other, +// the connection is dropped. +// +// The following information is sent to the server: +// Description Size Data Note +// Protocol ID 4 TRTP 0x54525450 +// Sub-protocol ID 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 +//Error code 4 Error code returned by the server (0 = no error) +func Handshake(conn net.Conn, buf []byte) error { + var h handshake + r := bytes.NewReader(buf) + if err := binary.Read(r, binary.BigEndian, &h); err != nil { + return err + } + + if h.Protocol != [4]byte{0x54, 0x52, 0x54, 0x50} { + return errors.New("invalid handshake") + } + + _, err := conn.Write([]byte{84, 82, 84, 80, 0, 0, 0, 0}) + return err +} + +// NewReply returns a reply Transaction with fields for the ClientConn +func (cc *ClientConn) NewReply(t *Transaction, fields ...Field) Transaction { + reply := Transaction{ + Flags: 0x00, + IsReply: 0x01, + Type: t.Type, + ID: t.ID, + clientID: cc.ID, + ErrorCode: []byte{0, 0, 0, 0}, + Fields: fields, + } + + return reply +} + +// NewErrReply returns an error reply Transaction with errMsg +func (cc *ClientConn) NewErrReply(t *Transaction, errMsg string) Transaction { + return Transaction{ + clientID: cc.ID, + Flags: 0x00, + IsReply: 0x01, + Type: []byte{0, 0}, + ID: t.ID, + ErrorCode: []byte{0, 0, 0, 1}, + Fields: []Field{ + NewField(fieldError, []byte(errMsg)), + }, + } +} diff --git a/hotline/client_conn_test.go b/hotline/client_conn_test.go new file mode 100644 index 0000000..d18f9b6 --- /dev/null +++ b/hotline/client_conn_test.go @@ -0,0 +1,53 @@ +package hotline + +import ( + "net" + "testing" +) + +func TestClientConn_handleTransaction(t *testing.T) { + type fields struct { + Connection net.Conn + ID *[]byte + Icon *[]byte + Flags *[]byte + UserName *[]byte + Account *Account + IdleTime *int + Server *Server + Version *[]byte + Idle bool + AutoReply *[]byte + } + type args struct { + transaction *Transaction + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cc := &ClientConn{ + Connection: tt.fields.Connection, + ID: tt.fields.ID, + Icon: tt.fields.Icon, + Flags: tt.fields.Flags, + UserName: tt.fields.UserName, + Account: tt.fields.Account, + IdleTime: tt.fields.IdleTime, + Server: tt.fields.Server, + Version: tt.fields.Version, + Idle: tt.fields.Idle, + AutoReply: tt.fields.AutoReply, + } + if err := cc.handleTransaction(tt.args.transaction); (err != nil) != tt.wantErr { + t.Errorf("handleTransaction() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} \ No newline at end of file diff --git a/hotline/config.go b/hotline/config.go new file mode 100644 index 0000000..e0c20cf --- /dev/null +++ b/hotline/config.go @@ -0,0 +1,21 @@ +package hotline + +const ( + defaultAgreement = "This is an agreement. Say you agree.\r" + defaultMessageBoard = "Welcome to Hotline\r" + defaultThreadedNews = "Categories:\n" +) + +type Config struct { + Name string `yaml:"Name"` // Name used for Tracker registration + Description string `yaml:"Description"` // Description used for Tracker registration + BannerID int `yaml:"BannerID"` // Unimplemented + FileRoot string `yaml:"FileRoot"` // Path to Files + EnableTrackerRegistration bool `yaml:"EnableTrackerRegistration"` // Toggle Tracker Registration + Trackers []string `yaml:"Trackers"` // List of trackers that the server should register with + NewsDelimiter string `yaml:"NewsDelimiter"` // String used to separate news posts + NewsDateFormat string `yaml:"NewsDateFormat"` // Go template string to customize news date format + MaxDownloads int `yaml:"MaxDownloads"` // Global simultaneous download limit + MaxDownloadsPerClient int `yaml:"MaxDownloadsPerClient"` // Per client simultaneous download limit + MaxConnectionsPerIP int `yaml:"MaxConnectionsPerIP"` // Max connections per IP +} diff --git a/hotline/field.go b/hotline/field.go new file mode 100644 index 0000000..161a9b2 --- /dev/null +++ b/hotline/field.go @@ -0,0 +1,120 @@ +package hotline + +import ( + "encoding/binary" + "github.com/jhalter/mobius/concat" +) + +const fieldError = 100 +const fieldData = 101 +const fieldUserName = 102 +const fieldUserID = 103 +const fieldUserIconID = 104 +const fieldUserLogin = 105 +const fieldUserPassword = 106 +const fieldRefNum = 107 +const fieldTransferSize = 108 +const fieldChatOptions = 109 +const fieldUserAccess = 110 +const fieldUserAlias = 111 +const fieldUserFlags = 112 +const fieldOptions = 113 +const fieldChatID = 114 +const fieldChatSubject = 115 +const fieldWaitingCount = 116 +const fieldVersion = 160 +const fieldCommunityBannerID = 161 +const fieldServerName = 162 +const fieldFileNameWithInfo = 200 +const fieldFileName = 201 +const fieldFilePath = 202 +const fieldFileTypeString = 205 +const fieldFileCreatorString = 206 +const fieldFileSize = 207 +const fieldFileCreateDate = 208 +const fieldFileModifyDate = 209 +const fieldFileComment = 210 +const fieldFileNewName = 211 +const fieldFileNewPath = 212 +const fieldFileType = 213 +const fieldQuotingMsg = 214 // Defined but unused in the Hotline Protocol spec +const fieldAutomaticResponse = 215 +const fieldFolderItemCount = 220 +const fieldUsernameWithInfo = 300 +const fieldNewsArtListData = 321 +const fieldNewsCatName = 322 +const fieldNewsCatListData15 = 323 +const fieldNewsPath = 325 +const fieldNewsArtID = 326 +const fieldNewsArtDataFlav = 327 +const fieldNewsArtTitle = 328 +const fieldNewsArtPoster = 329 +const fieldNewsArtDate = 330 +const fieldNewsArtPrevArt = 331 +const fieldNewsArtNextArt = 332 +const fieldNewsArtData = 333 +const fieldNewsArtFlags = 334 +const fieldNewsArtParentArt = 335 +const fieldNewsArt1stChildArt = 336 +const fieldNewsArtRecurseDel = 337 + +type Field struct { + ID []byte // Type of field + FieldSize []byte // Size of the data part + Data []byte // Actual field content +} + +type requiredField struct { + ID int + minLen int + maxLen int +} + +func NewField(id uint16, data []byte) Field { + idBytes := make([]byte, 2) + binary.BigEndian.PutUint16(idBytes, id) + + bs := make([]byte, 2) + binary.BigEndian.PutUint16(bs, uint16(len(data))) + + return Field{ + ID: idBytes, + FieldSize: bs, + Data: data, + } +} + +func (f Field) Payload() []byte { + return concat.Slices(f.ID, f.FieldSize, f.Data) +} + +type FileNameWithInfo struct { + Type string // file type code + Creator []byte // File creator code + FileSize uint32 // File Size in bytes + NameScript []byte // TODO: What is this? + NameSize []byte // Length of name field + Name string // File name +} + +func (f FileNameWithInfo) Payload() []byte { + name := []byte(f.Name) + nameSize := make([]byte, 2) + binary.BigEndian.PutUint16(nameSize, uint16(len(name))) + + kb := f.FileSize + + fSize := make([]byte, 4) + binary.BigEndian.PutUint32(fSize, kb) + + return concat.Slices( + []byte(f.Type), + f.Creator, + fSize, + []byte{0, 0, 0, 0}, + f.NameScript, + nameSize, + []byte(f.Name), + ) + +} diff --git a/hotline/field_test.go b/hotline/field_test.go new file mode 100644 index 0000000..fb686dc --- /dev/null +++ b/hotline/field_test.go @@ -0,0 +1,7 @@ +package hotline + +import "testing" + +func TestHello(t *testing.T) { + +} diff --git a/hotline/file_header.go b/hotline/file_header.go new file mode 100644 index 0000000..60c652e --- /dev/null +++ b/hotline/file_header.go @@ -0,0 +1,36 @@ +package hotline + +import ( + "encoding/binary" + "github.com/jhalter/mobius/concat" +) + +type FileHeader struct { + Size []byte // Total size of FileHeader payload + Type []byte // 0 for file, 1 for dir + FilePath []byte // encoded file path +} + +func NewFileHeader(fileName string, isDir bool) FileHeader { + fh := FileHeader{ + Size: make([]byte, 2), + Type: []byte{0x00, 0x00}, + FilePath: EncodeFilePath(fileName), + } + if isDir { + fh.Type = []byte{0x00, 0x01} + } + + encodedPathLen := uint16(len(fh.FilePath) + len(fh.Type)) + binary.BigEndian.PutUint16(fh.Size, encodedPathLen) + + return fh +} + +func (fh *FileHeader) Payload() []byte { + return concat.Slices( + fh.Size, + fh.Type, + fh.FilePath, + ) +} diff --git a/hotline/file_header_test.go b/hotline/file_header_test.go new file mode 100644 index 0000000..0194a60 --- /dev/null +++ b/hotline/file_header_test.go @@ -0,0 +1,92 @@ +package hotline + +import ( + "reflect" + "testing" +) + +func TestNewFileHeader(t *testing.T) { + type args struct { + fileName string + isDir bool + } + tests := []struct { + name string + args args + want FileHeader + }{ + { + name: "when path is file", + args: args{ + fileName: "foo", + isDir: false, + }, + want: FileHeader{ + Size: []byte{0x00, 0x0a}, + Type: []byte{0x00, 0x00}, + FilePath: EncodeFilePath("foo"), + }, + }, + { + name: "when path is dir", + args: args{ + fileName: "foo", + isDir: true, + }, + want: FileHeader{ + Size: []byte{0x00, 0x0a}, + Type: []byte{0x00, 0x01}, + FilePath: EncodeFilePath("foo"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NewFileHeader(tt.args.fileName, tt.args.isDir); !reflect.DeepEqual(got, tt.want) { + t.Errorf("NewFileHeader() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFileHeader_Payload(t *testing.T) { + type fields struct { + Size []byte + Type []byte + FilePath []byte + } + tests := []struct { + name string + fields fields + want []byte + }{ + { + name: "has expected payload bytes", + fields: fields{ + Size: []byte{0x00, 0x0a}, + Type: []byte{0x00, 0x00}, + FilePath: EncodeFilePath("foo"), + }, + want: []byte{ + 0x00, 0x0a, // total size + 0x00, 0x00, // type + 0x00, 0x01, // path item count + 0x00, 0x00, // path separator + 0x03, // pathName len + 0x66, 0x6f, 0x6f, // "foo" + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fh := &FileHeader{ + Size: tt.fields.Size, + Type: tt.fields.Type, + FilePath: tt.fields.FilePath, + } + if got := fh.Payload(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("Payload() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/hotline/file_path.go b/hotline/file_path.go new file mode 100644 index 0000000..71168dc --- /dev/null +++ b/hotline/file_path.go @@ -0,0 +1,56 @@ +package hotline + +import ( + "encoding/binary" + "strings" +) + +const pathSeparator = "/" // File path separator TODO: make configurable to support Windows + +// FilePathItem represents the file or directory portion of a delimited file path (e.g. foo and bar in "/foo/bar") +// 00 00 +// 09 +// 73 75 62 66 6f 6c 64 65 72 // "subfolder" +type FilePathItem struct { + Len byte + Name []byte +} + +func NewFilePathItem(b []byte) FilePathItem { + return FilePathItem{ + Len: b[2], + Name: b[3:], + } +} + +type FilePath struct { + PathItemCount []byte + PathItems []FilePathItem +} + +func NewFilePath(b []byte) FilePath { + if b == nil { + return FilePath{} + } + + fp := FilePath{PathItemCount: b[0:2]} + + // number of items in the path + pathItemLen := binary.BigEndian.Uint16(b[0:2]) + pathData := b[2:] + for i := uint16(0); i < pathItemLen; i++ { + segLen := pathData[2] + fp.PathItems = append(fp.PathItems, NewFilePathItem(pathData[:segLen+3])) + pathData = pathData[3+segLen:] + } + + return fp +} + +func (fp *FilePath) String() string { + var out []string + for _, i := range fp.PathItems { + out = append(out, string(i.Name)) + } + return strings.Join(out, pathSeparator) +} diff --git a/hotline/file_transfer.go b/hotline/file_transfer.go new file mode 100644 index 0000000..46b0061 --- /dev/null +++ b/hotline/file_transfer.go @@ -0,0 +1,29 @@ +package hotline + +import "fmt" + +// File transfer types +const ( + FileDownload = 0 + FileUpload = 1 + FolderDownload = 2 + FolderUpload = 3 +) + +type FileTransfer struct { + FileName []byte + FilePath []byte + ReferenceNumber []byte + Type int + TransferSize []byte // total size of all items in the folder. Only used in FolderUpload action + FolderItemCount []byte + BytesSent int + clientID uint16 +} + +func (ft *FileTransfer) String() string { + percentComplete := 10 + out := fmt.Sprintf("%s\t %v", ft.FileName, percentComplete) + + return out +} diff --git a/hotline/files.go b/hotline/files.go new file mode 100644 index 0000000..69c22b3 --- /dev/null +++ b/hotline/files.go @@ -0,0 +1,155 @@ +package hotline + +import ( + "encoding/binary" + "io/ioutil" + "os" + "path/filepath" + "strings" +) + +const defaultCreator = "TTXT" +const defaultType = "TEXT" + +var fileCreatorCodes = map[string]string{ + "sit": "SIT!", + "pdf": "CARO", +} + +var fileTypeCodes = map[string]string{ + "sit": "SIT!", + "jpg": "JPEG", + "pdf": "PDF ", +} + +func fileTypeFromFilename(fn string) string { + ext := strings.Split(fn, ".") + code := fileTypeCodes[ext[len(ext)-1]] + + if code == "" { + code = defaultType + } + + return code +} + +func fileCreatorFromFilename(fn string) string { + ext := strings.Split(fn, ".") + code := fileCreatorCodes[ext[len(ext)-1]] + if code == "" { + code = defaultCreator + } + + return code +} + +func getFileNameList(filePath string) ([]Field, error) { + var fields []Field + + files, err := ioutil.ReadDir(filePath) + if err != nil { + return fields, nil + } + + for _, file := range files { + var fileType string + var fileCreator []byte + var fileSize uint32 + if !file.IsDir() { + fileType = fileTypeFromFilename(file.Name()) + fileCreator = []byte(fileCreatorFromFilename(file.Name())) + fileSize = uint32(file.Size()) + } else { + fileType = "fldr" + fileCreator = make([]byte, 4) + + dir, err := ioutil.ReadDir(filePath + "/" + file.Name()) + if err != nil { + return fields, err + } + fileSize = uint32(len(dir)) + } + + fields = append(fields, NewField( + fieldFileNameWithInfo, + FileNameWithInfo{ + Type: fileType, + Creator: fileCreator, + FileSize: fileSize, + NameScript: []byte{0, 0}, + Name: file.Name(), + }.Payload(), + )) + } + + return fields, nil +} + +func CalcTotalSize(filePath string) ([]byte, error) { + var totalSize uint32 + err := filepath.Walk(filePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + totalSize += uint32(info.Size()) + + return nil + }) + if err != nil { + return nil, err + } + + bs := make([]byte, 4) + binary.BigEndian.PutUint32(bs, totalSize) + + return bs, nil +} + +func CalcItemCount(filePath string) ([]byte, error) { + var itemcount uint16 + err := filepath.Walk(filePath, func(path string, info os.FileInfo, err error) error { + itemcount += 1 + + if err != nil { + return err + } + + return nil + }) + if err != nil { + return nil, err + } + + bs := make([]byte, 2) + binary.BigEndian.PutUint16(bs, itemcount-1) + + return bs, nil +} + +func EncodeFilePath(filePath string) []byte { + pathSections := strings.Split(filePath, "/") + pathItemCount := make([]byte, 2) + binary.BigEndian.PutUint16(pathItemCount, uint16(len(pathSections))) + + bytes := pathItemCount + + for _, section := range pathSections { + bytes = append(bytes, []byte{0, 0}...) + + pathStr := []byte(section) + bytes = append(bytes, byte(len(pathStr))) + bytes = append(bytes, pathStr...) + } + + return bytes +} + +func ReadFilePath(filePathFieldData []byte) string { + fp := NewFilePath(filePathFieldData) + return fp.String() +} diff --git a/hotline/files_test.go b/hotline/files_test.go new file mode 100644 index 0000000..88c893f --- /dev/null +++ b/hotline/files_test.go @@ -0,0 +1,83 @@ +package hotline + +import ( + "bytes" + "os" + "reflect" + "testing" +) + +func TestEncodeFilePath(t *testing.T) { + var tests = []struct { + filePath string + want []byte + }{ + { + filePath: "kitten1.jpg", + want: []byte{ + 0x00, 0x01, // number of items in path + 0x00, 0x00, // leading path separator + 0x0b, // length of next path section (11) + 0x6b, 0x69, 0x74, 0x74, 0x65, 0x6e, 0x31, 0x2e, 0x6a, 0x70, 0x67, // kitten1.jpg + }, + }, + { + filePath: "foo/kitten1.jpg", + want: []byte{ + 0x00, 0x02, // number of items in path + 0x00, 0x00, + 0x03, + 0x66, 0x6f, 0x6f, + 0x00, 0x00, // leading path separator + 0x0b, // length of next path section (11) + 0x6b, 0x69, 0x74, 0x74, 0x65, 0x6e, 0x31, 0x2e, 0x6a, 0x70, 0x67, // kitten1.jpg + }, + }, + } + + for _, test := range tests { + got := EncodeFilePath(test.filePath) + if !bytes.Equal(got, test.want) { + t.Errorf("field mismatch: want: %#v got: %#v", test.want, got) + } + } +} + +func TestCalcTotalSize(t *testing.T) { + cwd, _ := os.Getwd() + defer func() {_ = os.Chdir(cwd)}() + + _ = os.Chdir("test/config/Files") + + type args struct { + filePath string + } + tests := []struct { + name string + args args + want []byte + wantErr bool + }{ + { + name: "Foo", + args: args{ + filePath: "test", + }, + want: []byte{0x00, 0x00, 0x18, 0x00}, + wantErr: false, + }, + // TODO: Add more test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := CalcTotalSize(tt.args.filePath) + if (err != nil) != tt.wantErr { + t.Errorf("CalcTotalSize() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("CalcTotalSize() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/hotline/flattened_file_object.go b/hotline/flattened_file_object.go new file mode 100644 index 0000000..dfdd8a8 --- /dev/null +++ b/hotline/flattened_file_object.go @@ -0,0 +1,239 @@ +package hotline + +import ( + "encoding/binary" + "fmt" + "os" +) + +type flattenedFileObject struct { + FlatFileHeader FlatFileHeader + FlatFileInformationForkHeader FlatFileInformationForkHeader + FlatFileInformationFork FlatFileInformationFork + FlatFileDataForkHeader FlatFileDataForkHeader + FileData []byte +} + +// FlatFileHeader is the first section of a "Flattened File Object". All fields have static values. +type FlatFileHeader struct { + Format []byte // Always "FILP" + Version []byte // Always 1 + RSVD []byte // Always empty zeros + ForkCount []byte // Always 2 +} + +// NewFlatFileHeader returns a FlatFileHeader struct +func NewFlatFileHeader() FlatFileHeader { + return FlatFileHeader{ + Format: []byte("FILP"), + Version: []byte{0, 1}, + RSVD: make([]byte, 16), + ForkCount: []byte{0, 2}, + } +} + +// FlatFileInformationForkHeader is the second section of a "Flattened File Object" +type FlatFileInformationForkHeader struct { + ForkType []byte // Always "INFO" + CompressionType []byte // Always 0; Compression was never implemented in the Hotline protocol + RSVD []byte // Always zeros + DataSize []byte // Size of the flat file information fork +} + +type FlatFileInformationFork struct { + Platform []byte // Operating System used. ("AMAC" or "MWIN") + TypeSignature []byte // File type signature + CreatorSignature []byte // File creator signature + Flags []byte + PlatformFlags []byte + RSVD []byte + CreateDate []byte + ModifyDate []byte + NameScript []byte // TODO: what is this? + NameSize []byte // Length of file name (Maximum 128 characters) + Name []byte // File name + CommentSize []byte // Length of file comment + Comment []byte // File comment +} + +func NewFlatFileInformationFork(fileName string) FlatFileInformationFork { + return FlatFileInformationFork{ + Platform: []byte("AMAC"), // TODO: Remove hardcode to support "AWIN" Platform (maybe?) + TypeSignature: []byte(fileTypeFromFilename(fileName)), // TODO: Don't infer types from filename + CreatorSignature: []byte(fileCreatorFromFilename(fileName)), // TODO: Don't infer types from filename + Flags: []byte{0, 0, 0, 0}, // TODO: What is this? + PlatformFlags: []byte{0, 0, 1, 0}, // TODO: What is this? + RSVD: make([]byte, 32), // Unimplemented in Hotline Protocol + CreateDate: []byte{0x07, 0x70, 0x00, 0x00, 0xba, 0x74, 0x24, 0x73}, // TODO: implement + ModifyDate: []byte{0x07, 0x70, 0x00, 0x00, 0xba, 0x74, 0x24, 0x73}, // TODO: implement + NameScript: make([]byte, 2), // TODO: What is this? + Name: []byte(fileName), + Comment: []byte("TODO"), // TODO: implement (maybe?) + } +} + +// Size of the flat file information fork, which is the fixed size of 72 bytes +// plus the number of bytes in the FileName +// TODO: plus the size of the Comment! +func (ffif FlatFileInformationFork) DataSize() []byte { + size := make([]byte, 4) + nameLen := len(ffif.Name) + //TODO: Can I do math directly on two byte slices? + dataSize := nameLen + 74 + + binary.BigEndian.PutUint32(size, uint32(dataSize)) + + return size +} + +func (ffo flattenedFileObject) TransferSize() []byte { + payloadSize := len(ffo.Payload()) + dataSize := binary.BigEndian.Uint32(ffo.FlatFileDataForkHeader.DataSize) + + transferSize := make([]byte, 4) + binary.BigEndian.PutUint32(transferSize, dataSize+uint32(payloadSize)) + + return transferSize +} + +func (ffif FlatFileInformationFork) ReadNameSize() []byte { + size := make([]byte, 2) + binary.BigEndian.PutUint16(size, uint16(len(ffif.Name))) + + return size +} + +type FlatFileDataForkHeader struct { + ForkType []byte + CompressionType []byte + RSVD []byte + DataSize []byte +} + +func NewFlatFileDataForkHeader() FlatFileDataForkHeader { + return FlatFileDataForkHeader{ + ForkType: []byte("DATA"), + CompressionType: []byte{0, 0, 0, 0}, + RSVD: []byte{0, 0, 0, 0}, + // DataSize: []byte{0, 0, 0x03, 0xc3}, + } +} + +// ReadFlattenedFileObject parses a byte slice into a flattenedFileObject +func ReadFlattenedFileObject(bytes []byte) flattenedFileObject { + nameSize := bytes[110:112] + bs := binary.BigEndian.Uint16(nameSize) + + nameEnd := 112 + bs + + commentSize := bytes[nameEnd : nameEnd+2] + commentLen := binary.BigEndian.Uint16(commentSize) + + commentStartPos := int(nameEnd) + 2 + commentEndPos := int(nameEnd) + 2 + int(commentLen) + + comment := bytes[commentStartPos:commentEndPos] + + //dataSizeField := bytes[nameEnd+14+commentLen : nameEnd+18+commentLen] + //dataSize := binary.BigEndian.Uint32(dataSizeField) + + ffo := flattenedFileObject{ + FlatFileHeader: FlatFileHeader{ + Format: bytes[0:4], + Version: bytes[4:6], + RSVD: bytes[6:22], + ForkCount: bytes[22:24], + }, + FlatFileInformationForkHeader: FlatFileInformationForkHeader{ + ForkType: bytes[24:28], + CompressionType: bytes[28:32], + RSVD: bytes[32:36], + DataSize: bytes[36:40], + }, + FlatFileInformationFork: FlatFileInformationFork{ + Platform: bytes[40:44], + TypeSignature: bytes[44:48], + CreatorSignature: bytes[48:52], + Flags: bytes[52:56], + PlatformFlags: bytes[56:60], + RSVD: bytes[60:92], + CreateDate: bytes[92:100], + ModifyDate: bytes[100:108], + NameScript: bytes[108:110], + NameSize: bytes[110:112], + Name: bytes[112:nameEnd], + CommentSize: bytes[nameEnd : nameEnd+2], + Comment: comment, + }, + FlatFileDataForkHeader: FlatFileDataForkHeader{ + ForkType: bytes[commentEndPos : commentEndPos+4], + CompressionType: bytes[commentEndPos+4 : commentEndPos+8], + RSVD: bytes[commentEndPos+8 : commentEndPos+12], + DataSize: bytes[commentEndPos+12 : commentEndPos+16], + }, + } + + return ffo +} + +func (f flattenedFileObject) Payload() []byte { + var out []byte + out = append(out, f.FlatFileHeader.Format...) + out = append(out, f.FlatFileHeader.Version...) + out = append(out, f.FlatFileHeader.RSVD...) + out = append(out, f.FlatFileHeader.ForkCount...) + + out = append(out, []byte("INFO")...) + out = append(out, []byte{0, 0, 0, 0}...) + out = append(out, make([]byte, 4)...) + out = append(out, f.FlatFileInformationFork.DataSize()...) + + out = append(out, f.FlatFileInformationFork.Platform...) + out = append(out, f.FlatFileInformationFork.TypeSignature...) + out = append(out, f.FlatFileInformationFork.CreatorSignature...) + out = append(out, f.FlatFileInformationFork.Flags...) + out = append(out, f.FlatFileInformationFork.PlatformFlags...) + out = append(out, f.FlatFileInformationFork.RSVD...) + out = append(out, f.FlatFileInformationFork.CreateDate...) + out = append(out, f.FlatFileInformationFork.ModifyDate...) + out = append(out, f.FlatFileInformationFork.NameScript...) + out = append(out, f.FlatFileInformationFork.ReadNameSize()...) + out = append(out, f.FlatFileInformationFork.Name...) + + // TODO: Implement commentlen and comment field + out = append(out, []byte{0, 0}...) + + out = append(out, f.FlatFileDataForkHeader.ForkType...) + out = append(out, f.FlatFileDataForkHeader.CompressionType...) + out = append(out, f.FlatFileDataForkHeader.RSVD...) + out = append(out, f.FlatFileDataForkHeader.DataSize...) + + return out +} + +func NewFlattenedFileObject(filePath string, fileName string) (flattenedFileObject, error) { + file, err := os.Open(fmt.Sprintf("%v/%v", filePath, fileName)) + if err != nil { + return flattenedFileObject{}, err + } + defer file.Close() + + fileInfo, err := file.Stat() + if err != nil { + return flattenedFileObject{}, err + } + + dataSize := make([]byte, 4) + binary.BigEndian.PutUint32(dataSize, uint32(fileInfo.Size())) + + return flattenedFileObject{ + FlatFileHeader: NewFlatFileHeader(), + FlatFileInformationFork: NewFlatFileInformationFork(fileName), + FlatFileDataForkHeader: FlatFileDataForkHeader{ + ForkType: []byte("DATA"), + CompressionType: []byte{0, 0, 0, 0}, + RSVD: []byte{0, 0, 0, 0}, + DataSize: dataSize, + }, + }, nil +} diff --git a/hotline/flattened_file_object_test.go b/hotline/flattened_file_object_test.go new file mode 100644 index 0000000..6a6953f --- /dev/null +++ b/hotline/flattened_file_object_test.go @@ -0,0 +1,35 @@ +package hotline + +import ( + "bytes" + "encoding/hex" + "testing" +) + +func TestReadFlattenedFileObject(t *testing.T) { + testData, _ := hex.DecodeString("46494c500001000000000000000000000000000000000002494e464f000000000000000000000052414d414354455854747478740000000000000100000000000000000000000000000000000000000000000000000000000000000007700000ba74247307700000ba74247300000008746573742e74787400004441544100000000000000000000000474657374") + + ffo := ReadFlattenedFileObject(testData) + + format := ffo.FlatFileHeader.Format + want := []byte("FILP") + if !bytes.Equal(format, want) { + t.Errorf("ReadFlattenedFileObject() = %q, want %q", format, want) + } +} +// +//func TestNewFlattenedFileObject(t *testing.T) { +// ffo := NewFlattenedFileObject("test/config/files", "testfile.txt") +// +// dataSize := ffo.FlatFileDataForkHeader.DataSize +// want := []byte{0, 0, 0, 0x17} +// if bytes.Compare(dataSize, want) != 0 { +// t.Errorf("%q, want %q", dataSize, want) +// } +// +// comment := ffo.FlatFileInformationFork.Comment +// want = []byte("Test Comment") +// if bytes.Compare(ffo.FlatFileInformationFork.Comment, want) != 0 { +// t.Errorf("%q, want %q", comment, want) +// } +//} diff --git a/hotline/news.go b/hotline/news.go new file mode 100644 index 0000000..a9e5d72 --- /dev/null +++ b/hotline/news.go @@ -0,0 +1,274 @@ +package hotline + +import ( + "bytes" + "crypto/rand" + "encoding/binary" + "log" + "sort" + "time" +) + +type ThreadedNews struct { + Categories map[string]NewsCategoryListData15 `yaml:"Categories"` +} + +type NewsCategoryListData15 struct { + Type []byte `yaml:"Type"` //Size 2 ; Bundle (2) or category (3) + Name string `yaml:"Name"` // + Articles map[uint32]*NewsArtData `yaml:"Articles"` // Optional, if Type is Category + SubCats map[string]NewsCategoryListData15 `yaml:"SubCats"` + Count []byte // Article or SubCategory count Size 2 + AddSN []byte // Size 4 + DeleteSN []byte // Size 4 + GUID []byte // Size 16 +} + +func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData { + var newsArts []NewsArtList + var newsArtsPayload []byte + + for i, art := range newscat.Articles { + ID := make([]byte, 4) + binary.BigEndian.PutUint32(ID, i) + + newArt := NewsArtList{ + ID: ID, + TimeStamp: art.Date, + ParentID: art.ParentArt, + Flags: []byte{0, 0, 0, 0}, + FlavorCount: []byte{0, 0}, + Title: []byte(art.Title), + Poster: []byte(art.Poster), + ArticleSize: art.DataSize(), + } + newsArts = append(newsArts, newArt) + } + + sort.Sort(byID(newsArts)) + + for _, v := range newsArts { + newsArtsPayload = append(newsArtsPayload, v.Payload()...) + } + + nald := NewsArtListData{ + ID: []byte{0, 0, 0, 0}, + Name: []byte{}, + Description: []byte{}, + NewsArtList: newsArtsPayload, + } + + return nald +} + +// NewsArtData repsents a single news article +type NewsArtData struct { + Title string `yaml:"Title"` + Poster string `yaml:"Poster"` + Date []byte `yaml:"Date"` //size 8 + PrevArt []byte `yaml:"PrevArt"` //size 4 + NextArt []byte `yaml:"NextArt"` //size 4 + ParentArt []byte `yaml:"ParentArt"` //size 4 + FirstChildArt []byte `yaml:"FirstChildArtArt"` //size 4 + DataFlav []byte `yaml:"DataFlav"` // "text/plain" + Data string `yaml:"Data"` +} + +func (art *NewsArtData) DataSize() []byte { + dataLen := make([]byte, 2) + binary.BigEndian.PutUint16(dataLen, uint16(len(art.Data))) + + return dataLen +} + +type NewsArtListData struct { + ID []byte `yaml:"ID"` // Size 4 + Name []byte `yaml:"Name"` + Description []byte `yaml:"Description"` // not used? + NewsArtList []byte // List of articles Optional (if article count > 0) +} + +func (nald *NewsArtListData) Payload() []byte { + count := make([]byte, 4) + binary.BigEndian.PutUint32(count, uint32(len(nald.NewsArtList))) + + out := append(nald.ID, count...) + out = append(out, []byte{uint8(len(nald.Name))}...) + out = append(out, nald.Name...) + out = append(out, []byte{uint8(len(nald.Description))}...) + out = append(out, nald.Description...) + out = append(out, nald.NewsArtList...) + + return out +} + +// NewsArtList is a summarized ver sion of a NewArtData record for display in list view +type NewsArtList struct { + ID []byte // Size 4 + TimeStamp []byte // Year (2 bytes), milliseconds (2 bytes) and seconds (4 bytes) + ParentID []byte // Size 4 + Flags []byte // Size 4 + FlavorCount []byte // Size 2 + // Title size 1 + Title []byte // string + // Poster size 1 + // Poster Poster string + Poster []byte + FlavorList []NewsFlavorList + // Flavor list… Optional (if flavor count > 0) + ArticleSize []byte // Size 2 +} + +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) +} + +func (nal *NewsArtList) Payload() []byte { + out := append(nal.ID, nal.TimeStamp...) + out = append(out, nal.ParentID...) + out = append(out, nal.Flags...) + + out = append(out, []byte{0, 1}...) + + out = append(out, []byte{uint8(len(nal.Title))}...) + out = append(out, nal.Title...) + out = append(out, []byte{uint8(len(nal.Poster))}...) + out = append(out, nal.Poster...) + out = append(out, []byte{0x0a, 0x74, 0x65, 0x78, 0x74, 0x2f, 0x70, 0x6c, 0x61, 0x69, 0x6e}...) // TODO: wat? + out = append(out, nal.ArticleSize...) + + return out +} + +type NewsFlavorList struct { + // Flavor size 1 + // Flavor text size MIME type string + // Article size 2 +} + +func (newscat *NewsCategoryListData15) Payload() []byte { + count := make([]byte, 2) + binary.BigEndian.PutUint16(count, uint16(len(newscat.Articles)+len(newscat.SubCats))) + + out := append(newscat.Type, count...) + + if bytes.Equal(newscat.Type, []byte{0, 3}) { + // Generate a random GUID + b := make([]byte, 16) + _, err := rand.Read(b) + if err != nil { + log.Fatal(err) + } + + out = append(out, b...) // GUID + out = append(out, []byte{0, 0, 0, 1}...) // Add SN (TODO: not sure what this is) + out = append(out, []byte{0, 0, 0, 2}...) // Delete SN (TODO: not sure what this is) + } + + out = append(out, newscat.nameLen()...) + out = append(out, []byte(newscat.Name)...) + + return out +} + +// ReadNewsCategoryListData parses a byte slice into a NewsCategoryListData15 struct +// For use on the client side +func ReadNewsCategoryListData(payload []byte) NewsCategoryListData15 { + ncld := NewsCategoryListData15{ + Type: payload[0:2], + Count: payload[2:4], + } + + if bytes.Equal(ncld.Type, []byte{0, 3}) { + ncld.GUID = payload[4:20] + ncld.AddSN = payload[20:24] + ncld.AddSN = payload[24:28] + ncld.Name = string(payload[29:]) + } else { + ncld.Name = string(payload[5:]) + } + + return ncld +} + +func (newscat *NewsCategoryListData15) nameLen() []byte { + return []byte{uint8(len(newscat.Name))} +} + +type NewsPath struct { + Paths []string +} + +func (np *NewsPath) Payload() []byte { + var out []byte + + count := make([]byte, 2) + binary.BigEndian.PutUint16(count, uint16(len(np.Paths))) + + out = append(out, count...) + for _, p := range np.Paths { + pLen := byte(len(p)) + out = append(out, []byte{0, 0}...) // what is this? + out = append(out, pLen) + out = append(out, []byte(p)...) + } + + return out +} + +func ReadNewsPath(newsPath []byte) []string { + if len(newsPath) == 0 { + return []string{} + } + 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 +} + +// News article date field contains this structure: +// Year 2 +// Milliseconds 2 (seriously?) +// Seconds 4 +func NewsDate() []byte { + t := time.Now() + ms := []byte{0, 0} + seconds := []byte{0, 0, 0, 0} + + year := []byte{0, 0} + binary.BigEndian.PutUint16(year, uint16(t.Year())) + + yearStart := time.Date(t.Year(), time.January, 1, 0, 0, 0, 0, time.Local) + + binary.BigEndian.PutUint32(seconds, uint32(t.Sub(yearStart).Seconds())) + + date := append(year, ms...) + date = append(date, seconds...) + + return date +} diff --git a/hotline/server.go b/hotline/server.go new file mode 100644 index 0000000..e51f673 --- /dev/null +++ b/hotline/server.go @@ -0,0 +1,1035 @@ +package hotline + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "go.uber.org/zap" + "io" + "io/ioutil" + "log" + "math/big" + "math/rand" + "net" + "os" + "path" + "path/filepath" + "runtime/debug" + "sort" + "strings" + "sync" + "time" + + "golang.org/x/crypto/bcrypt" + "gopkg.in/yaml.v2" +) + +const ( + userIdleSeconds = 300 // time in seconds before an inactive user is marked idle + idleCheckInterval = 10 // time in seconds to check for idle users + trackerUpdateFrequency = 300 // time in seconds between tracker re-registration +) + +type Server struct { + Interface string + Port int + Accounts map[string]*Account + Agreement []byte + Clients map[uint16]*ClientConn + FlatNews []byte + ThreadedNews *ThreadedNews + FileTransfers map[uint32]*FileTransfer + Config *Config + ConfigDir string + Logger *zap.SugaredLogger + PrivateChats map[uint32]*PrivateChat + NextGuestID *uint16 + TrackerPassID []byte + Stats *Stats + + APIListener net.Listener + FileListener net.Listener + + newsReader io.Reader + newsWriter io.WriteCloser + + outbox chan Transaction + + mux sync.Mutex + flatNewsMux sync.Mutex +} + +type PrivateChat struct { + Subject string + ClientConn map[uint16]*ClientConn +} + +func (s *Server) ListenAndServe(ctx context.Context, cancelRoot context.CancelFunc) error { + s.Logger.Infow("Hotline server started", "version", VERSION) + var wg sync.WaitGroup + + wg.Add(1) + go func() { s.Logger.Fatal(s.Serve(ctx, cancelRoot, s.APIListener)) }() + + wg.Add(1) + go func() { s.Logger.Fatal(s.ServeFileTransfers(s.FileListener)) }() + + wg.Wait() + + return nil +} + +func (s *Server) APIPort() int { + return s.APIListener.Addr().(*net.TCPAddr).Port +} + +func (s *Server) ServeFileTransfers(ln net.Listener) error { + s.Logger.Infow("Hotline file transfer server started", "Addr", fmt.Sprintf(":%v", s.Port+1)) + + for { + conn, err := ln.Accept() + if err != nil { + return err + } + + go func() { + if err := s.TransferFile(conn); err != nil { + s.Logger.Errorw("file transfer error", "reason", err) + } + }() + } +} + +func (s *Server) sendTransaction(t Transaction) error { + requestNum := binary.BigEndian.Uint16(t.Type) + clientID, err := byteToInt(*t.clientID) + if err != nil { + return err + } + + s.mux.Lock() + client := s.Clients[uint16(clientID)] + s.mux.Unlock() + if client == nil { + return errors.New("invalid client") + } + userName := string(*client.UserName) + login := client.Account.Login + + handler := TransactionHandlers[requestNum] + + var n int + if n, err = client.Connection.Write(t.Payload()); err != nil { + return err + } + s.Logger.Debugw("Sent Transaction", + "name", userName, + "login", login, + "IsReply", t.IsReply, + "type", handler.Name, + "sentBytes", n, + "remoteAddr", client.Connection.RemoteAddr(), + ) + return nil +} + +func (s *Server) Serve(ctx context.Context, cancelRoot context.CancelFunc, ln net.Listener) error { + s.Logger.Infow("Hotline server started", "Addr", fmt.Sprintf(":%v", s.Port)) + + for { + conn, err := ln.Accept() + if err != nil { + s.Logger.Errorw("error accepting connection", "err", err) + } + + go func() { + for { + t := <-s.outbox + go func() { + if err := s.sendTransaction(t); err != nil { + s.Logger.Errorw("error sending transaction", "err", err) + } + }() + } + }() + go func() { + if err := s.handleNewConnection(conn); err != nil { + if err == io.EOF { + s.Logger.Infow("Client disconnected", "RemoteAddr", conn.RemoteAddr()) + } else { + s.Logger.Errorw("error serving request", "RemoteAddr", conn.RemoteAddr(), "err", err) + } + } + }() + } +} + +const ( + agreementFile = "Agreement.txt" + messageBoardFile = "MessageBoard.txt" + threadedNewsFile = "ThreadedNews.yaml" +) + +// NewServer constructs a new Server from a config dir +func NewServer(configDir, netInterface string, netPort int, logger *zap.SugaredLogger) (*Server, error) { + server := Server{ + Port: netPort, + Accounts: make(map[string]*Account), + Config: new(Config), + Clients: make(map[uint16]*ClientConn), + FileTransfers: make(map[uint32]*FileTransfer), + PrivateChats: make(map[uint32]*PrivateChat), + ConfigDir: configDir, + Logger: logger, + NextGuestID: new(uint16), + outbox: make(chan Transaction), + Stats: &Stats{StartTime: time.Now()}, + ThreadedNews: &ThreadedNews{}, + TrackerPassID: make([]byte, 4), + } + + ln, err := net.Listen("tcp", fmt.Sprintf("%s:%v", netInterface, netPort)) + if err != nil { + return nil, err + } + server.APIListener = ln + + if netPort != 0 { + netPort += 1 + } + + ln2, err := net.Listen("tcp", fmt.Sprintf("%s:%v", netInterface, netPort)) + server.FileListener = ln2 + if err != nil { + return nil, err + } + + // generate a new random passID for tracker registration + if _, err := rand.Read(server.TrackerPassID); err != nil { + return nil, err + } + + server.Logger.Debugw("Loading Agreement", "path", configDir+agreementFile) + if server.Agreement, err = os.ReadFile(configDir + agreementFile); err != nil { + return nil, err + } + + if server.FlatNews, err = os.ReadFile(configDir + "MessageBoard.txt"); err != nil { + return nil, err + } + + if err := server.loadThreadedNews(configDir + "ThreadedNews.yaml"); err != nil { + return nil, err + } + + if err := server.loadConfig(configDir + "config.yaml"); err != nil { + return nil, err + } + + if err := server.loadAccounts(configDir + "Users/"); err != nil { + return nil, err + } + + server.Config.FileRoot = configDir + "Files/" + + *server.NextGuestID = 1 + + if server.Config.EnableTrackerRegistration { + go func() { + for { + tr := TrackerRegistration{ + Port: []byte{0x15, 0x7c}, + UserCount: server.userCount(), + PassID: server.TrackerPassID, + Name: server.Config.Name, + Description: server.Config.Description, + } + for _, t := range server.Config.Trackers { + server.Logger.Infof("Registering with tracker %v", t) + + if err := register(t, tr); err != nil { + server.Logger.Errorw("unable to register with tracker %v", "error", err) + } + } + + time.Sleep(trackerUpdateFrequency * time.Second) + } + }() + } + + // 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) +} + +func (s *Server) keepaliveHandler() { + for { + time.Sleep(idleCheckInterval * time.Second) + s.mux.Lock() + + for _, c := range s.Clients { + *c.IdleTime += idleCheckInterval + if *c.IdleTime > userIdleSeconds && !c.Idle { + c.Idle = true + + flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(*c.Flags))) + flagBitmap.SetBit(flagBitmap, userFlagAway, 1) + binary.BigEndian.PutUint16(*c.Flags, uint16(flagBitmap.Int64())) + + c.sendAll( + tranNotifyChangeUser, + NewField(fieldUserID, *c.ID), + NewField(fieldUserFlags, *c.Flags), + NewField(fieldUserName, *c.UserName), + NewField(fieldUserIconID, *c.Icon), + ) + } + } + s.mux.Unlock() + } +} + +func (s *Server) writeThreadedNews() error { + s.mux.Lock() + defer s.mux.Unlock() + + out, err := yaml.Marshal(s.ThreadedNews) + if err != nil { + return err + } + err = ioutil.WriteFile( + s.ConfigDir+"ThreadedNews.yaml", + out, + 0666, + ) + return err +} + +func (s *Server) NewClientConn(conn net.Conn) *ClientConn { + s.mux.Lock() + defer s.mux.Unlock() + + clientConn := &ClientConn{ + ID: &[]byte{0, 0}, + Icon: &[]byte{0, 0}, + Flags: &[]byte{0, 0}, + UserName: &[]byte{}, + Connection: conn, + Server: s, + Version: &[]byte{}, + IdleTime: new(int), + AutoReply: &[]byte{}, + Transfers: make(map[int][]*FileTransfer), + } + *s.NextGuestID++ + ID := *s.NextGuestID + + *clientConn.IdleTime = 0 + + binary.BigEndian.PutUint16(*clientConn.ID, ID) + s.Clients[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 []byte) error { + s.mux.Lock() + defer s.mux.Unlock() + + account := Account{ + Login: login, + Name: name, + Password: hashAndSalt([]byte(password)), + Access: &access, + } + out, err := yaml.Marshal(&account) + if err != nil { + return err + } + s.Accounts[login] = &account + + return ioutil.WriteFile(s.ConfigDir+"Users/"+login+".yaml", out, 0666) +} + +// DeleteUser deletes the user account +func (s *Server) DeleteUser(login string) error { + s.mux.Lock() + defer s.mux.Unlock() + + delete(s.Accounts, login) + + return os.Remove(s.ConfigDir + "Users/" + login + ".yaml") +} + +func (s *Server) connectedUsers() []Field { + s.mux.Lock() + defer s.mux.Unlock() + + var connectedUsers []Field + for _, c := range s.Clients { + user := User{ + ID: *c.ID, + Icon: *c.Icon, + Flags: *c.Flags, + Name: string(*c.UserName), + } + connectedUsers = append(connectedUsers, NewField(fieldUsernameWithInfo, user.Payload())) + } + return connectedUsers +} + +// loadThreadedNews loads the threaded news data from disk +func (s *Server) loadThreadedNews(threadedNewsPath string) error { + fh, err := os.Open(threadedNewsPath) + if err != nil { + return err + } + decoder := yaml.NewDecoder(fh) + decoder.SetStrict(true) + + return decoder.Decode(s.ThreadedNews) +} + +// loadAccounts loads account data from disk +func (s *Server) loadAccounts(userDir string) error { + matches, err := filepath.Glob(path.Join(userDir, "*.yaml")) + if err != nil { + return err + } + + if len(matches) == 0 { + return errors.New("no user accounts found in " + userDir) + } + + for _, file := range matches { + fh, err := os.Open(file) + if err != nil { + return err + } + + account := Account{} + decoder := yaml.NewDecoder(fh) + decoder.SetStrict(true) + if err := decoder.Decode(&account); err != nil { + return err + } + + s.Accounts[account.Login] = &account + } + return nil +} + +func (s *Server) loadConfig(path string) error { + fh, err := os.Open(path) + if err != nil { + return err + } + + decoder := yaml.NewDecoder(fh) + decoder.SetStrict(true) + err = decoder.Decode(s.Config) + if err != nil { + return err + } + return nil +} + +const ( + minTransactionLen = 22 // minimum length of any transaction +) + +// handleNewConnection takes a new net.Conn and performs the initial login sequence +func (s *Server) handleNewConnection(conn net.Conn) error { + handshakeBuf := make([]byte, 12) // handshakes are always 12 bytes in length + if _, err := conn.Read(handshakeBuf); err != nil { + return err + } + if err := Handshake(conn, handshakeBuf[:12]); err != nil { + return err + } + + buf := make([]byte, 1024) + readLen, err := conn.Read(buf) + if readLen < minTransactionLen { + return err + } + if err != nil { + return err + } + + clientLogin, _, err := ReadTransaction(buf[:readLen]) + if err != nil { + return err + } + + c := s.NewClientConn(conn) + defer c.Disconnect() + defer func() { + if r := recover(); r != nil { + fmt.Println("stacktrace from panic: \n" + string(debug.Stack())) + c.Server.Logger.Errorw("PANIC", "err", r, "trace", string(debug.Stack())) + c.Disconnect() + } + }() + + encodedLogin := clientLogin.GetField(fieldUserLogin).Data + encodedPassword := clientLogin.GetField(fieldUserPassword).Data + *c.Version = clientLogin.GetField(fieldVersion).Data + + var login string + for _, char := range encodedLogin { + login += string(rune(255 - uint(char))) + } + if login == "" { + login = GuestAccount + } + + // If authentication fails, send error reply and close connection + if !c.Authenticate(login, encodedPassword) { + rep := c.NewErrReply(clientLogin, "Incorrect login.") + if _, err := conn.Write(rep.Payload()); err != nil { + return err + } + return fmt.Errorf("incorrect login") + } + + if clientLogin.GetField(fieldUserName).Data != nil { + *c.UserName = clientLogin.GetField(fieldUserName).Data + } + + if clientLogin.GetField(fieldUserIconID).Data != nil { + *c.Icon = clientLogin.GetField(fieldUserIconID).Data + } + + c.Account = c.Server.Accounts[login] + + if c.Authorize(accessDisconUser) { + *c.Flags = []byte{0, 2} + } + + s.Logger.Infow("Client connection received", "login", login, "version", *c.Version, "RemoteAddr", conn.RemoteAddr().String()) + + s.outbox <- c.NewReply(clientLogin, + NewField(fieldVersion, []byte{0x00, 0xbe}), + NewField(fieldCommunityBannerID, []byte{0x00, 0x01}), + NewField(fieldServerName, []byte(s.Config.Name)), + ) + + // Send user access privs so client UI knows how to behave + c.Server.outbox <- *NewTransaction(tranUserAccess, c.ID, NewField(fieldUserAccess, *c.Account.Access)) + + // Show agreement to client + c.Server.outbox <- *NewTransaction(tranShowAgreement, c.ID, NewField(fieldData, s.Agreement)) + + if _, err := c.notifyNewUserHasJoined(); err != nil { + return err + } + c.Server.Stats.LoginCount += 1 + + const readBuffSize = 1024000 // 1KB - TODO: what should this be? + const maxTranSize = 1024000 + tranBuff := make([]byte, 0) + tReadlen := 0 + // Infinite loop where take action on incoming client requests until the connection is closed + for { + buf = make([]byte, readBuffSize) + tranBuff = tranBuff[tReadlen:] + + readLen, err := c.Connection.Read(buf) + if err != nil { + return err + } + tranBuff = append(tranBuff, buf[:readLen]...) + + // We may have read multiple requests worth of bytes from Connection.Read. readTransactions splits them + // into a slice of transactions + var transactions []Transaction + if transactions, tReadlen, err = readTransactions(tranBuff); err != nil { + c.Server.Logger.Errorw("Error handling transaction", "err", err) + } + + // iterate over all of the transactions that were parsed from the byte slice and handle them + for _, t := range transactions { + if err := c.handleTransaction(&t); err != nil { + c.Server.Logger.Errorw("Error handling transaction", "err", err) + } + } + } +} + +func hashAndSalt(pwd []byte) string { + // Use GenerateFromPassword to hash & salt pwd. + // MinCost is just an integer constant provided by the bcrypt + // package along with DefaultCost & MaxCost. + // The cost can be any value you want provided it isn't lower + // than the MinCost (4) + hash, err := bcrypt.GenerateFromPassword(pwd, bcrypt.MinCost) + if err != nil { + log.Println(err) + } + // GenerateFromPassword returns a byte slice so we need to + // convert the bytes to a string and return it + return string(hash) +} + +// NewTransactionRef generates a random ID for the file transfer. The Hotline client includes this ID +// in the file transfer request payload, and the file transfer server will use it to map the request +// to a transfer +func (s *Server) NewTransactionRef() []byte { + transactionRef := make([]byte, 4) + rand.Read(transactionRef) + + return transactionRef +} + +func (s *Server) NewPrivateChat(cc *ClientConn) []byte { + s.mux.Lock() + defer s.mux.Unlock() + + randID := make([]byte, 4) + rand.Read(randID) + data := binary.BigEndian.Uint32(randID[:]) + + s.PrivateChats[data] = &PrivateChat{ + Subject: "", + ClientConn: make(map[uint16]*ClientConn), + } + s.PrivateChats[data].ClientConn[cc.uint16ID()] = cc + + return randID +} + +const dlFldrActionSendFile = 1 +const dlFldrActionResumeFile = 2 +const dlFldrActionNextFile = 3 + +func (s *Server) TransferFile(conn net.Conn) error { + defer func() { _ = conn.Close() }() + + buf := make([]byte, 1024) + if _, err := conn.Read(buf); err != nil { + return err + } + + t, err := NewReadTransfer(buf) + if err != nil { + return err + } + + transferRefNum := binary.BigEndian.Uint32(t.ReferenceNumber[:]) + fileTransfer := s.FileTransfers[transferRefNum] + + switch fileTransfer.Type { + case FileDownload: + fullFilePath := fmt.Sprintf("%v/%v", s.Config.FileRoot+string(fileTransfer.FilePath), string(fileTransfer.FileName)) + + ffo, err := NewFlattenedFileObject( + s.Config.FileRoot+string(fileTransfer.FilePath), + string(fileTransfer.FileName), + ) + if err != nil { + return err + } + + s.Logger.Infow("File download started", "filePath", fullFilePath, "transactionRef", fileTransfer.ReferenceNumber, "RemoteAddr", conn.RemoteAddr().String()) + + // Start by sending flat file object to client + if _, err := conn.Write(ffo.Payload()); err != nil { + return err + } + + file, err := os.Open(fullFilePath) + if err != nil { + return err + } + + sendBuffer := make([]byte, 1048576) + for { + var bytesRead int + if bytesRead, err = file.Read(sendBuffer); err == io.EOF { + break + } + + fileTransfer.BytesSent += bytesRead + + delete(s.FileTransfers, transferRefNum) + + if _, err := conn.Write(sendBuffer[:bytesRead]); err != nil { + return err + } + } + case FileUpload: + if _, err := conn.Read(buf); err != nil { + return err + } + + ffo := ReadFlattenedFileObject(buf) + payloadLen := len(ffo.Payload()) + fileSize := int(binary.BigEndian.Uint32(ffo.FlatFileDataForkHeader.DataSize)) + + destinationFile := s.Config.FileRoot + ReadFilePath(fileTransfer.FilePath) + "/" + string(fileTransfer.FileName) + s.Logger.Infow( + "File upload started", + "transactionRef", fileTransfer.ReferenceNumber, + "RemoteAddr", conn.RemoteAddr().String(), + "size", fileSize, + "dstFile", destinationFile, + ) + + newFile, err := os.Create(destinationFile) + if err != nil { + return err + } + + defer func() { _ = newFile.Close() }() + + const buffSize = 1024 + + if _, err := newFile.Write(buf[payloadLen:]); err != nil { + return err + } + receivedBytes := buffSize - payloadLen + + for { + if (fileSize - receivedBytes) < buffSize { + s.Logger.Infow( + "File upload complete", + "transactionRef", fileTransfer.ReferenceNumber, + "RemoteAddr", conn.RemoteAddr().String(), + "size", fileSize, + "dstFile", destinationFile, + ) + + if _, err := io.CopyN(newFile, conn, int64(fileSize-receivedBytes)); err != nil { + return fmt.Errorf("file transfer failed: %s", err) + } + return nil + } + + // Copy N bytes from conn to upload file + n, err := io.CopyN(newFile, conn, buffSize) + if err != nil { + return err + } + receivedBytes += int(n) + } + case FolderDownload: + // Folder Download flow: + // 1. Get filePath from the Transfer + // 2. Iterate over files + // 3. For each file: + // Send file header to client + // The client can reply in 3 ways: + // + // 1. If type is an odd number (unknown type?), or file download for the current file is completed: + // client sends []byte{0x00, 0x03} to tell the server to continue to the next file + // + // 2. If download of a file is to be resumed: + // client sends: + // []byte{0x00, 0x02} // download folder action + // [2]byte // Resume data size + // []byte file resume data (see myField_FileResumeData) + // + // 3. Otherwise download of the file is requested and client sends []byte{0x00, 0x01} + // + // When download is requested (case 2 or 3), server replies with: + // [4]byte - file size + // []byte - Flattened File Object + // + // After every file download, client could request next file with: + // []byte{0x00, 0x03} + // + // This notifies the server to send the next item header + + fh := NewFilePath(fileTransfer.FilePath) + fullFilePath := fmt.Sprintf("%v/%v", s.Config.FileRoot+fh.String(), string(fileTransfer.FileName)) + + basePathLen := len(fullFilePath) + + readBuffer := make([]byte, 1024) + + s.Logger.Infow("Start folder download", "path", fullFilePath, "ReferenceNumber", fileTransfer.ReferenceNumber, "RemoteAddr", conn.RemoteAddr()) + + i := 0 + _ = filepath.Walk(fullFilePath+"/", func(path string, info os.FileInfo, _ error) error { + i += 1 + subPath := path[basePathLen-2:] + s.Logger.Infow("Sending fileheader", "i", i, "path", path, "fullFilePath", fullFilePath, "subPath", subPath, "IsDir", info.IsDir()) + + fileHeader := NewFileHeader(subPath, info.IsDir()) + + if i == 1 { + return nil + } + + // Send the file header to client + if _, err := conn.Write(fileHeader.Payload()); err != nil { + s.Logger.Errorf("error sending file header: %v", err) + return err + } + + // Read the client's Next Action request + //TODO: Remove hardcoded behavior and switch behaviors based on the next action send + if _, err := conn.Read(readBuffer); err != nil { + s.Logger.Errorf("error reading next action: %v", err) + return err + } + + s.Logger.Infow("Client folder download action", "action", fmt.Sprintf("%X", readBuffer[0:2])) + + if info.IsDir() { + return nil + } + + splitPath := strings.Split(path, "/") + //strings.Join(splitPath[:len(splitPath)-1], "/") + + ffo, err := NewFlattenedFileObject(strings.Join(splitPath[:len(splitPath)-1], "/"), info.Name()) + if err != nil { + return err + } + s.Logger.Infow("File download started", + "fileName", info.Name(), + "transactionRef", fileTransfer.ReferenceNumber, + "RemoteAddr", conn.RemoteAddr().String(), + "TransferSize", fmt.Sprintf("%x", ffo.TransferSize()), + ) + + // Send file size to client + if _, err := conn.Write(ffo.TransferSize()); err != nil { + s.Logger.Error(err) + return err + } + + // Send file bytes to client + if _, err := conn.Write(ffo.Payload()); err != nil { + s.Logger.Error(err) + return err + } + + file, err := os.Open(path) + if err != nil { + return err + } + + sendBuffer := make([]byte, 1048576) + totalBytesSent := len(ffo.Payload()) + + for { + bytesRead, err := file.Read(sendBuffer) + if err == io.EOF { + // Read the client's Next Action request + //TODO: Remove hardcoded behavior and switch behaviors based on the next action send + if _, err := conn.Read(readBuffer); err != nil { + s.Logger.Errorf("error reading next action: %v", err) + return err + } + break + } + + sentBytes, readErr := conn.Write(sendBuffer[:bytesRead]) + totalBytesSent += sentBytes + if readErr != nil { + return err + } + } + return nil + }) + + case FolderUpload: + dstPath := s.Config.FileRoot + ReadFilePath(fileTransfer.FilePath) + "/" + string(fileTransfer.FileName) + s.Logger.Infow( + "Folder upload started", + "transactionRef", fileTransfer.ReferenceNumber, + "RemoteAddr", conn.RemoteAddr().String(), + "dstPath", dstPath, + "TransferSize", fileTransfer.TransferSize, + "FolderItemCount", fileTransfer.FolderItemCount, + ) + + // Check if the target folder exists. If not, create it. + if _, err := os.Stat(dstPath); os.IsNotExist(err) { + s.Logger.Infow("Target path does not exist; Creating...", "dstPath", dstPath) + if err := os.Mkdir(dstPath, 0777); err != nil { + s.Logger.Error(err) + } + } + + readBuffer := make([]byte, 1024) + + // Begin the folder upload flow by sending the "next file action" to client + if _, err := conn.Write([]byte{0, dlFldrActionNextFile}); err != nil { + return err + } + + fileSize := make([]byte, 4) + itemCount := binary.BigEndian.Uint16(fileTransfer.FolderItemCount) + + for i := uint16(0); i < itemCount; i++ { + if _, err := conn.Read(readBuffer); err != nil { + return err + } + fu := readFolderUpload(readBuffer) + + s.Logger.Infow( + "Folder upload continued", + "transactionRef", fmt.Sprintf("%x", fileTransfer.ReferenceNumber), + "RemoteAddr", conn.RemoteAddr().String(), + "FormattedPath", fu.FormattedPath(), + "IsFolder", fmt.Sprintf("%x", fu.IsFolder), + "PathItemCount", binary.BigEndian.Uint16(fu.PathItemCount), + ) + + if bytes.Equal(fu.IsFolder, []byte{0, 1}) { + if _, err := os.Stat(dstPath + "/" + fu.FormattedPath()); os.IsNotExist(err) { + s.Logger.Infow("Target path does not exist; Creating...", "dstPath", dstPath) + if err := os.Mkdir(dstPath+"/"+fu.FormattedPath(), 0777); err != nil { + s.Logger.Error(err) + } + } + + // Tell client to send next file + if _, err := conn.Write([]byte{0, dlFldrActionNextFile}); err != nil { + s.Logger.Error(err) + return err + } + } else { + // TODO: Check if we have the full file already. If so, send dlFldrAction_NextFile to client to skip. + // TODO: Check if we have a partial file already. If so, send dlFldrAction_ResumeFile to client to resume upload. + // Send dlFldrAction_SendFile to client to begin transfer + if _, err := conn.Write([]byte{0, dlFldrActionSendFile}); err != nil { + return err + } + + if _, err := conn.Read(fileSize); err != nil { + fmt.Println("Error reading:", err.Error()) // TODO: handle + } + + s.Logger.Infow("Starting file transfer", "fileNum", i+1, "totalFiles", itemCount, "fileSize", fileSize) + + if err := transferFile(conn, dstPath+"/"+fu.FormattedPath()); err != nil { + s.Logger.Error(err) + } + + // Tell client to send next file + if _, err := conn.Write([]byte{0, dlFldrActionNextFile}); err != nil { + s.Logger.Error(err) + return err + } + + // Client sends "MACR" after the file. Read and discard. + // TODO: This doesn't seem to be documented. What is this? Maybe resource fork? + if _, err := conn.Read(readBuffer); err != nil { + return err + } + } + } + s.Logger.Infof("Folder upload complete") + } + + return nil +} + +func transferFile(conn net.Conn, dst string) error { + const buffSize = 1024 + buf := make([]byte, buffSize) + + // Read first chunk of bytes from conn; this will be the Flat File Object and initial chunk of file bytes + if _, err := conn.Read(buf); err != nil { + return err + } + ffo := ReadFlattenedFileObject(buf) + payloadLen := len(ffo.Payload()) + fileSize := int(binary.BigEndian.Uint32(ffo.FlatFileDataForkHeader.DataSize)) + + newFile, err := os.Create(dst) + if err != nil { + return err + } + defer func() { _ = newFile.Close() }() + if _, err := newFile.Write(buf[payloadLen:]); err != nil { + return err + } + receivedBytes := buffSize - payloadLen + + for { + if (fileSize - receivedBytes) < buffSize { + _, err := io.CopyN(newFile, conn, int64(fileSize-receivedBytes)) + return err + } + + // Copy N bytes from conn to upload file + n, err := io.CopyN(newFile, conn, buffSize) + if err != nil { + return err + } + receivedBytes += int(n) + } +} + +// 00 28 // DataSize +// 00 00 // IsFolder +// 00 02 // PathItemCount +// +// 00 00 +// 09 +// 73 75 62 66 6f 6c 64 65 72 // "subfolder" +// +// 00 00 +// 15 +// 73 75 62 66 6f 6c 64 65 72 2d 74 65 73 74 66 69 6c 65 2d 35 6b // "subfolder-testfile-5k" +func readFolderUpload(buf []byte) folderUpload { + dataLen := binary.BigEndian.Uint16(buf[0:2]) + + fu := folderUpload{ + DataSize: buf[0:2], // Size of this structure (not including data size element itself) + IsFolder: buf[2:4], + PathItemCount: buf[4:6], + FileNamePath: buf[6 : dataLen+2], + } + + return fu +} + +type folderUpload struct { + DataSize []byte + IsFolder []byte + PathItemCount []byte + FileNamePath []byte +} + +func (fu *folderUpload) FormattedPath() string { + pathItemLen := binary.BigEndian.Uint16(fu.PathItemCount) + + var pathSegments []string + pathData := fu.FileNamePath + + for i := uint16(0); i < pathItemLen; i++ { + segLen := pathData[2] + pathSegments = append(pathSegments, string(pathData[3:3+segLen])) + pathData = pathData[3+segLen:] + } + + return strings.Join(pathSegments, pathSeparator) +} + +// 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[uint16]*ClientConn) (clients []*ClientConn) { + for _, c := range unsortedClients { + clients = append(clients, c) + } + sort.Sort(byClientID(clients)) + return clients +} diff --git a/hotline/server_blackbox_test.go b/hotline/server_blackbox_test.go new file mode 100644 index 0000000..3718bd2 --- /dev/null +++ b/hotline/server_blackbox_test.go @@ -0,0 +1,321 @@ +package hotline + +// Guest login +// Admin login is +// +//type testCase struct { +// name string // test case description +// account Account // Account struct for a user that will test transaction will execute under +// request *Transaction // transaction that will be sent by the client to the server +// setup func() // Optional test-specific setup required for the scenario +// teardown func() // Optional test-specific teardown for the scenario +// mockHandler map[int]*mockClientHandler +//} +// +//func (tt *testCase) Setup(srv *Server) error { +// if err := srv.NewUser(tt.account.Login, tt.account.Name, NegatedUserString([]byte(tt.account.Password)), *tt.account.Access); err != nil { +// return err +// } +// +// if tt.setup != nil { +// tt.setup() +// } +// +// return nil +//} +// +//func (tt *testCase) Teardown(srv *Server) error { +// if err := srv.DeleteUser(tt.account.Login); err != nil { +// return err +// } +// +// if tt.teardown != nil { +// tt.teardown() +// } +// +// return nil +//} +// +//func NewTestLogger() *zap.SugaredLogger { +// encoderCfg := zap.NewProductionEncoderConfig() +// encoderCfg.TimeKey = "timestamp" +// encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder +// +// core := zapcore.NewCore( +// zapcore.NewConsoleEncoder(encoderCfg), +// zapcore.Lock(os.Stdout), +// zap.DebugLevel, +// ) +// +// cores := []zapcore.Core{core} +// l := zap.New(zapcore.NewTee(cores...)) +// defer func() { _ = l.Sync() }() +// return l.Sugar() +//} +// +//func StartTestServer() (*Server, context.Context, context.CancelFunc) { +// ctx, cancelRoot := context.WithCancel(context.Background()) +// +// srv, err := NewServer("test/config/", "localhost", 0, NewTestLogger()) +// if err != nil { +// panic(err) +// } +// +// go func() { +// err := srv.ListenAndServe(ctx, cancelRoot) +// if err != nil { +// panic(err) +// } +// }() +// +// return srv, ctx, cancelRoot +//} +// +//func TestHandshake(t *testing.T) { +// srv, _, cancelFunc := StartTestServer() +// defer cancelFunc() +// +// port := srv.APIListener.Addr().(*net.TCPAddr).Port +// +// conn, err := net.Dial("tcp", fmt.Sprintf(":%v", port)) +// if err != nil { +// t.Fatal(err) +// } +// defer conn.Close() +// +// conn.Write([]byte{0x54, 0x52, 0x54, 0x50, 0x00, 0x01, 0x00, 0x00}) +// +// replyBuf := make([]byte, 8) +// _, _ = conn.Read(replyBuf) +// +// want := []byte{84, 82, 84, 80, 0, 0, 0, 0} +// if bytes.Compare(replyBuf, want) != 0 { +// t.Errorf("%q, want %q", replyBuf, want) +// } +// +//} +// +////func TestLogin(t *testing.T) { +//// +//// tests := []struct { +//// name string +//// client *Client +//// }{ +//// { +//// name: "when login is successful", +//// client: NewClient("guest", NewTestLogger()), +//// }, +//// } +//// for _, test := range tests { +//// t.Run(test.name, func(t *testing.T) { +//// +//// }) +//// } +////} +// +//func TestNewUser(t *testing.T) { +// srv, _, _ := StartTestServer() +// +// tests := []testCase{ +// //{ +// // name: "a valid new account", +// // mockHandler: func() mockClientHandler { +// // mh := mockClientHandler{} +// // mh.On("Handle", mock.AnythingOfType("*hotline.Client"), mock.MatchedBy(func(t *Transaction) bool { +// // println("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@") +// // spew.Dump(t.Type) +// // spew.Dump(bytes.Equal(t.Type, []byte{0x01, 0x5e})) +// // //if !bytes.Equal(t.GetField(fieldError).Data, []byte("You are not allowed to create new accounts.")) { +// // // return false +// // //} +// // return bytes.Equal(t.Type, []byte{0x01, 0x5e}, +// // ) +// // })).Return( +// // []Transaction{}, nil, +// // ) +// // +// // clientHandlers[tranNewUser] = mh +// // return mh +// // }(), +// // client: func() *Client { +// // c := NewClient("testUser", NewTestLogger()) +// // return c +// // }(), +// // teardown: func() { +// // _ = srv.DeleteUser("testUser") +// // }, +// // account: Account{ +// // Login: "test", +// // Name: "unnamed", +// // Password: "test", +// // Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, +// // }, +// // request: NewTransaction( +// // tranNewUser, nil, +// // NewField(fieldUserLogin, []byte(NegatedUserString([]byte("testUser")))), +// // NewField(fieldUserName, []byte("testUserName")), +// // NewField(fieldUserPassword, []byte(NegatedUserString([]byte("testPw")))), +// // NewField(fieldUserAccess, []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}), +// // ), +// // want: &Transaction{ +// // Fields: []Field{}, +// // }, +// //}, +// //{ +// // name: "a newUser request from a user without the required access", +// // mockHandler: func() *mockClientHandler { +// // mh := mockClientHandler{} +// // mh.On("Handle", mock.AnythingOfType("*hotline.Client"), mock.MatchedBy(func(t *Transaction) bool { +// // if !bytes.Equal(t.GetField(fieldError).Data, []byte("You are not allowed to create new accounts.")) { +// // return false +// // } +// // return bytes.Equal(t.Type, []byte{0x01, 0x5e}) +// // })).Return( +// // []Transaction{}, nil, +// // ) +// // return &mh +// // }(), +// // teardown: func() { +// // _ = srv.DeleteUser("testUser") +// // }, +// // account: Account{ +// // Login: "test", +// // Name: "unnamed", +// // Password: "test", +// // Access: &[]byte{0, 0, 0, 0, 0, 0, 0, 0}, +// // }, +// // request: NewTransaction( +// // tranNewUser, nil, +// // NewField(fieldUserLogin, []byte(NegatedUserString([]byte("testUser")))), +// // NewField(fieldUserName, []byte("testUserName")), +// // NewField(fieldUserPassword, []byte(NegatedUserString([]byte("testPw")))), +// // NewField(fieldUserAccess, []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}), +// // ), +// //}, +// { +// name: "when user does not have required permission", +// mockHandler: func() map[int]*mockClientHandler { +// mockHandlers := make(map[int]*mockClientHandler) +// +// mh := mockClientHandler{} +// mh.On("Handle", mock.AnythingOfType("*hotline.Client"), mock.MatchedBy(func(t *Transaction) bool { +// return t.equal(Transaction{ +// Type: []byte{0x01, 0x5e}, +// IsReply: 1, +// ErrorCode: []byte{0, 0, 0, 1}, +// Fields: []Field{ +// NewField(fieldError, []byte("You are not allowed to create new accounts.")), +// }, +// }) +// })).Return( +// []Transaction{}, nil, +// ) +// mockHandlers[tranNewUser] = &mh +// +// return mockHandlers +// }(), +// +// teardown: func() { +// _ = srv.DeleteUser("testUser") +// }, +// account: Account{ +// Login: "test", +// Name: "unnamed", +// Password: "test", +// Access: &[]byte{0, 0, 0, 0, 0, 0, 0, 0}, +// }, +// request: NewTransaction( +// tranNewUser, nil, +// NewField(fieldUserLogin, []byte(NegatedUserString([]byte("testUser")))), +// NewField(fieldUserName, []byte("testUserName")), +// NewField(fieldUserPassword, []byte(NegatedUserString([]byte("testPw")))), +// NewField(fieldUserAccess, []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}), +// ), +// }, +// +// //{ +// // name: "a request to create a user that already exists", +// // setup: func() { +// // +// // }, +// // teardown: func() { +// // _ = srv.DeleteUser("testUser") +// // }, +// // account: Account{ +// // Login: "test", +// // Name: "unnamed", +// // Password: "test", +// // Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, +// // }, +// // request: NewTransaction( +// // tranNewUser, nil, +// // NewField(fieldUserLogin, []byte(NegatedUserString([]byte("guest")))), +// // NewField(fieldUserName, []byte("testUserName")), +// // NewField(fieldUserPassword, []byte(NegatedUserString([]byte("testPw")))), +// // NewField(fieldUserAccess, []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}), +// // ), +// // want: &Transaction{ +// // Fields: []Field{ +// // NewField(fieldError, []byte("Cannot create account guest because there is already an account with that login.")), +// // }, +// // }, +// //}, +// } +// +// for _, test := range tests { +// t.Run(test.name, func(t *testing.T) { +// test.Setup(srv) +// +// // move to Setup? +// c := NewClient(test.account.Name, NewTestLogger()) +// err := c.JoinServer(fmt.Sprintf(":%v", srv.APIPort()), test.account.Login, test.account.Password) +// if err != nil { +// t.Errorf("login failed: %v", err) +// } +// // end move to Setup?? +// +// for key, value := range test.mockHandler { +// c.Handlers[uint16(key)] = value +// } +// +// // send test case request +// _ = c.Send(*test.request) +// +// //time.Sleep(1 * time.Second) +// // === +// +// transactions, _ := readN(c.Connection, 1) +// for _, t := range transactions { +// _ = c.HandleTransaction(&t) +// } +// +// // === +// +// for _, handler := range test.mockHandler { +// handler.AssertExpectations(t) +// } +// +// test.Teardown(srv) +// }) +// } +//} +// +//// equal is a utility function used only in tests that determines if transactions are equal enough +//func (t Transaction) equal(otherT Transaction) bool { +// t.ID = []byte{0, 0, 0, 0} +// otherT.ID = []byte{0, 0, 0, 0} +// +// t.TotalSize = []byte{0, 0, 0, 0} +// otherT.TotalSize = []byte{0, 0, 0, 0} +// +// t.DataSize = []byte{0, 0, 0, 0} +// otherT.DataSize = []byte{0, 0, 0, 0} +// +// t.ParamCount = []byte{0, 0} +// otherT.ParamCount = []byte{0, 0} +// +// //spew.Dump(t) +// //spew.Dump(otherT) +// +// return reflect.DeepEqual(t, otherT) +//} diff --git a/hotline/server_test.go b/hotline/server_test.go new file mode 100644 index 0000000..2e88202 --- /dev/null +++ b/hotline/server_test.go @@ -0,0 +1,806 @@ +package hotline + +// +//import ( +// "bytes" +// "fmt" +// "github.com/google/go-cmp/cmp" +// "io/ioutil" +// "math/big" +// "net" +// "strings" +// "sync" +// "testing" +//) +// +//type transactionTest struct { +// description string // Human understandable description +// account Account // Account struct for a user that will test transaction will execute under +// request Transaction // transaction that will be sent by the client to the server +// want Transaction // transaction that the client expects to receive in response +// setup func() // Optional setup required for the test scenario +// teardown func() // Optional teardown for test scenario +//} +// +//func (tt *transactionTest) Setup(srv *Server) error { +// if err := srv.NewUser(tt.account.Login, tt.account.Name, NegatedUserString([]byte(tt.account.Password)), tt.account.Access); err != nil { +// return err +// } +// +// if tt.setup != nil { +// tt.setup() +// } +// +// return nil +//} +// +//func (tt *transactionTest) Teardown(srv *Server) error { +// if err := srv.DeleteUser(tt.account.Login); err != nil { +// return err +// } +// +// if tt.teardown != nil { +// tt.teardown() +// } +// +// return nil +//} +// +//// StartTestServer +//func StartTestServer() (srv *Server, lnPort int) { +// hotlineServer, _ := NewServer("test/config/") +// ln, err := net.Listen("tcp", ":0") +// +// if err != nil { +// panic(err) +// } +// go func() { +// for { +// conn, _ := ln.Accept() +// go hotlineServer.HandleConnection(conn) +// } +// }() +// return hotlineServer, ln.Addr().(*net.TCPAddr).Port +//} +// +//func StartTestClient(serverPort int, login, passwd string) (*Client, error) { +// c := NewClient("") +// +// err := c.JoinServer(fmt.Sprintf(":%v", serverPort), login, passwd) +// if err != nil { +// return nil, err +// } +// +// return c, nil +//} +// +//func StartTestServerWithClients(clientCount int) ([]*Client, int) { +// _, serverPort := StartTestServer() +// +// var clients []*Client +// for i := 0; i < clientCount; i++ { +// client, err := StartTestClient(serverPort, "admin", "") +// if err != nil { +// panic(err) +// } +// clients = append(clients, client) +// } +// clients[0].ReadN(2) +// +// return clients, serverPort +//} +// + + +////func TestHandleTranAgreed(t *testing.T) { +//// clients, _ := StartTestServerWithClients(2) +//// +//// chatMsg := "Test Chat" +//// +//// // Assert that both clients should receive the user join notification +//// var wg sync.WaitGroup +//// for _, client := range clients { +//// wg.Add(1) +//// go func(wg *sync.WaitGroup, c *Client) { +//// defer wg.Done() +//// +//// receivedMsg := c.ReadTransactions()[0].GetField(fieldData).Data +//// +//// want := []byte(fmt.Sprintf("test: %s\r", chatMsg)) +//// if bytes.Compare(receivedMsg, want) != 0 { +//// t.Errorf("%q, want %q", receivedMsg, want) +//// } +//// }(&wg, client) +//// } +//// +//// trans := clients[1].ReadTransactions() +//// spew.Dump(trans) +//// +//// // Send the agreement +//// clients[1].Connection.Write( +//// NewTransaction( +//// tranAgreed, 0, +//// []Field{ +//// NewField(fieldUserName, []byte("testUser")), +//// NewField(fieldUserIconID, []byte{0x00,0x07}), +//// }, +//// ).Payload(), +//// ) +//// +//// wg.Wait() +////} +// +//func TestChatSend(t *testing.T) { +// //srvPort := StartTestServer() +// // +// //senderClient := NewClient("senderClient") +// //senderClient.JoinServer(fmt.Sprintf(":%v", srvPort), "", "") +// // +// //receiverClient := NewClient("receiverClient") +// //receiverClient.JoinServer(fmt.Sprintf(":%v", srvPort), "", "") +// +// clients, _ := StartTestServerWithClients(2) +// +// chatMsg := "Test Chat" +// +// // Both clients should receive the chatMsg +// var wg sync.WaitGroup +// for _, client := range clients { +// wg.Add(1) +// go func(wg *sync.WaitGroup, c *Client) { +// defer wg.Done() +// +// receivedMsg := c.ReadTransactions()[0].GetField(fieldData).Data +// +// want := []byte(fmt.Sprintf(" test: %s\r", chatMsg)) +// if bytes.Compare(receivedMsg, want) != 0 { +// t.Errorf("%q, want %q", receivedMsg, want) +// } +// }(&wg, client) +// } +// +// // Send the chatMsg +// clients[1].Send( +// NewTransaction( +// tranChatSend, 0, +// []Field{ +// NewField(fieldData, []byte(chatMsg)), +// }, +// ), +// ) +// +// wg.Wait() +//} +// +//func TestSetClientUserInfo(t *testing.T) { +// clients, _ := StartTestServerWithClients(2) +// +// newIcon := []byte{0x00, 0x01} +// newUserName := "newName" +// +// // Both clients should receive the chatMsg +// var wg sync.WaitGroup +// for _, client := range clients { +// wg.Add(1) +// go func(wg *sync.WaitGroup, c *Client) { +// defer wg.Done() +// +// tran := c.ReadTransactions()[0] +// +// want := []byte(newUserName) +// got := tran.GetField(fieldUserName).Data +// if bytes.Compare(got, want) != 0 { +// t.Errorf("%q, want %q", got, want) +// } +// }(&wg, client) +// } +// +// _, err := clients[1].Connection.Write( +// NewTransaction( +// tranSetClientUserInfo, 0, +// []Field{ +// NewField(fieldUserIconID, newIcon), +// NewField(fieldUserName, []byte(newUserName)), +// }, +// ).Payload(), +// ) +// if err != nil { +// t.Errorf("%v", err) +// } +// +// wg.Wait() +//} +// +//// TestSendInstantMsg tests that client A can send an instant message to client B +//// +//func TestSendInstantMsg(t *testing.T) { +// clients, _ := StartTestServerWithClients(2) +// +// instantMsg := "Test IM" +// +// var wg sync.WaitGroup +// wg.Add(1) +// go func(wg *sync.WaitGroup, c *Client) { +// defer wg.Done() +// +// tran := c.WaitForTransaction(tranServerMsg) +// +// receivedMsg := tran.GetField(fieldData).Data +// want := []byte(fmt.Sprintf("%s", instantMsg)) +// if bytes.Compare(receivedMsg, want) != 0 { +// t.Errorf("%q, want %q", receivedMsg, want) +// } +// }(&wg, clients[0]) +// +// _ = clients[1].Send( +// NewTransaction(tranGetUserNameList, 0, []Field{}), +// ) +// //connectedUsersTran := clients[1].ReadTransactions()[0] +// ////connectedUsers := connectedUsersTran.Fields[0].Data[0:2] +// //spew.Dump(connectedUsersTran.Fields) +// //firstUserID := connectedUsersTran.Fields[0].Data[0:2] +// // +// //spew.Dump(firstUserID) +// +// // Send the IM +// err := clients[1].Send( +// NewTransaction( +// tranSendInstantMsg, 0, +// []Field{ +// NewField(fieldData, []byte(instantMsg)), +// NewField(fieldUserName, clients[1].UserName), +// NewField(fieldUserID, []byte{0, 2}), +// NewField(fieldOptions, []byte{0, 1}), +// }, +// ), +// ) +// if err != nil { +// t.Error(err) +// } +// +// wg.Wait() +//} +// +//func TestOldPostNews(t *testing.T) { +// clients, _ := StartTestServerWithClients(2) +// +// newsPost := "Test News Post" +// +// var wg sync.WaitGroup +// wg.Add(1) +// go func(wg *sync.WaitGroup, c *Client) { +// defer wg.Done() +// +// receivedMsg := c.ReadTransactions()[0].GetField(fieldData).Data +// +// if strings.Contains(string(receivedMsg), newsPost) == false { +// t.Errorf("news post missing") +// } +// }(&wg, clients[0]) +// +// clients[1].Connection.Write( +// NewTransaction( +// tranOldPostNews, 0, +// []Field{ +// NewField(fieldData, []byte(newsPost)), +// }, +// ).Payload(), +// ) +// +// wg.Wait() +//} +// +//// TODO: Fixme +////func TestGetFileNameList(t *testing.T) { +//// clients, _ := StartTestServerWithClients(2) +//// +//// clients[0].Connection.Write( +//// NewTransaction( +//// tranGetFileNameList, 0, +//// []Field{}, +//// ).Payload(), +//// ) +//// +//// ts := clients[0].ReadTransactions() +//// testfileSit := ReadFileNameWithInfo(ts[0].Fields[1].Data) +//// +//// want := "testfile.sit" +//// got := testfileSit.Name +//// diff := cmp.Diff(want, got) +//// if diff != "" { +//// t.Fatalf(diff) +//// } +//// if testfileSit.Name != "testfile.sit" { +//// t.Errorf("news post missing") +//// t.Errorf("%q, want %q", testfileSit.Name, "testfile.sit") +//// } +////} +// +//func TestNewsCategoryList(t *testing.T) { +// clients, _ := StartTestServerWithClients(2) +// client := clients[0] +// +// client.Send( +// NewTransaction( +// tranGetNewsCatNameList, 0, +// []Field{}, +// ), +// ) +// +// ts := client.ReadTransactions() +// cats := ts[0].GetFields(fieldNewsCatListData15) +// +// newsCat := ReadNewsCategoryListData(cats[0].Data) +// want := "TestBundle" +// got := newsCat.Name +// diff := cmp.Diff(want, got) +// if diff != "" { +// t.Fatalf(diff) +// } +// +// newsBundle := ReadNewsCategoryListData(cats[1].Data) +// want = "TestCat" +// got = newsBundle.Name +// diff = cmp.Diff(want, got) +// if diff != "" { +// t.Fatalf(diff) +// } +//} +// +//func TestNestedNewsCategoryList(t *testing.T) { +// clients, _ := StartTestServerWithClients(2) +// client := clients[0] +// newsPath := NewsPath{ +// []string{ +// "TestBundle", +// "NestedBundle", +// }, +// } +// +// _, err := client.Connection.Write( +// NewTransaction( +// tranGetNewsCatNameList, 0, +// []Field{ +// NewField( +// fieldNewsPath, +// newsPath.Payload(), +// ), +// }, +// ).Payload(), +// ) +// if err != nil { +// t.Errorf("%v", err) +// } +// +// ts := client.ReadTransactions() +// cats := ts[0].GetFields(fieldNewsCatListData15) +// +// newsCat := ReadNewsCategoryListData(cats[0].Data) +// want := "NestedCat" +// got := newsCat.Name +// diff := cmp.Diff(want, got) +// if diff != "" { +// t.Fatalf(diff) +// } +//} +// +//func TestFileDownload(t *testing.T) { +// clients, _ := StartTestServerWithClients(2) +// client := clients[0] +// +// type want struct { +// fileSize []byte +// transferSize []byte +// waitingCount []byte +// refNum []byte +// } +// var tests = []struct { +// fileName string +// want want +// }{ +// { +// fileName: "testfile.sit", +// want: want{ +// fileSize: []byte{0x0, 0x0, 0x0, 0x13}, +// transferSize: []byte{0x0, 0x0, 0x0, 0xa1}, +// }, +// }, +// { +// fileName: "testfile.txt", +// want: want{ +// fileSize: []byte{0x0, 0x0, 0x0, 0x17}, +// transferSize: []byte{0x0, 0x0, 0x0, 0xa5}, +// }, +// }, +// } +// +// for _, test := range tests { +// _, err := client.Connection.Write( +// NewTransaction( +// tranDownloadFile, 0, +// []Field{ +// NewField(fieldFileName, []byte(test.fileName)), +// NewField(fieldFilePath, []byte("")), +// }, +// ).Payload(), +// ) +// if err != nil { +// t.Errorf("%v", err) +// } +// tran := client.ReadTransactions()[0] +// +// if got := tran.GetField(fieldFileSize).Data; bytes.Compare(got, test.want.fileSize) != 0 { +// t.Errorf("TestFileDownload: fileSize got %#v, want %#v", got, test.want.fileSize) +// } +// +// if got := tran.GetField(fieldTransferSize).Data; bytes.Compare(got, test.want.transferSize) != 0 { +// t.Errorf("TestFileDownload: fieldTransferSize: %s: got %#v, want %#v", test.fileName, got, test.want.transferSize) +// } +// } +//} +// +//func TestFileUpload(t *testing.T) { +// clients, _ := StartTestServerWithClients(2) +// client := clients[0] +// +// var tests = []struct { +// fileName string +// want Transaction +// }{ +// { +// fileName: "testfile.sit", +// want: Transaction{ +// Fields: []Field{ +// NewField(fieldRefNum, []byte{0x16, 0x3f, 0x5f, 0xf}), +// }, +// }, +// }, +// } +// +// for _, test := range tests { +// err := client.Send( +// NewTransaction( +// tranUploadFile, 0, +// []Field{ +// NewField(fieldFileName, []byte(test.fileName)), +// NewField(fieldFilePath, []byte("")), +// }, +// ), +// ) +// if err != nil { +// t.Errorf("%v", err) +// } +// tran := client.ReadTransactions()[0] +// +// for _, f := range test.want.Fields { +// got := tran.GetField(f.Uint16ID()).Data +// want := test.want.GetField(fieldRefNum).Data +// if bytes.Compare(got, want) != 0 { +// t.Errorf("xxx: yyy got %#v, want %#v", got, want) +// } +// } +// } +//} +// +//// TODO: Make canonical +//func TestNewUser(t *testing.T) { +// srv, port := StartTestServer() +// +// var tests = []struct { +// description string +// setup func() +// teardown func() +// account Account +// request Transaction +// want Transaction +// }{ +// { +// description: "a valid new account", +// teardown: func() { +// _ = srv.DeleteUser("testUser") +// }, +// account: Account{ +// Login: "test", +// Name: "unnamed", +// Password: "test", +// Access: []byte{255, 255, 255, 255, 255, 255, 255, 255}, +// }, +// request: NewTransaction( +// tranNewUser, 0, +// []Field{ +// NewField(fieldUserLogin, []byte(NegatedUserString([]byte("testUser")))), +// NewField(fieldUserName, []byte("testUserName")), +// NewField(fieldUserPassword, []byte(NegatedUserString([]byte("testPw")))), +// NewField(fieldUserAccess, []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}), +// }, +// ), +// want: Transaction{ +// Fields: []Field{}, +// }, +// }, +// { +// description: "a newUser request from a user without the required access", +// teardown: func() { +// _ = srv.DeleteUser("testUser") +// }, +// account: Account{ +// Login: "test", +// Name: "unnamed", +// Password: "test", +// Access: []byte{0, 0, 0, 0, 0, 0, 0, 0}, +// }, +// request: NewTransaction( +// tranNewUser, 0, +// []Field{ +// NewField(fieldUserLogin, []byte(NegatedUserString([]byte("testUser")))), +// NewField(fieldUserName, []byte("testUserName")), +// NewField(fieldUserPassword, []byte(NegatedUserString([]byte("testPw")))), +// NewField(fieldUserAccess, []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}), +// }, +// ), +// want: Transaction{ +// Fields: []Field{ +// NewField(fieldError, []byte("You are not allowed to create new accounts.")), +// }, +// }, +// }, +// { +// description: "a request to create a user that already exists", +// teardown: func() { +// _ = srv.DeleteUser("testUser") +// }, +// account: Account{ +// Login: "test", +// Name: "unnamed", +// Password: "test", +// Access: []byte{255, 255, 255, 255, 255, 255, 255, 255}, +// }, +// request: NewTransaction( +// tranNewUser, 0, +// []Field{ +// NewField(fieldUserLogin, []byte(NegatedUserString([]byte("guest")))), +// NewField(fieldUserName, []byte("testUserName")), +// NewField(fieldUserPassword, []byte(NegatedUserString([]byte("testPw")))), +// NewField(fieldUserAccess, []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}), +// }, +// ), +// want: Transaction{ +// Fields: []Field{ +// NewField(fieldError, []byte("Cannot create account guest because there is already an account with that login.")), +// }, +// }, +// }, +// } +// +// for _, test := range tests { +// if test.setup != nil { +// test.setup() +// } +// +// if err := srv.NewUser(test.account.Login, test.account.Name, NegatedUserString([]byte(test.account.Password)), test.account.Access); err != nil { +// t.Errorf("%v", err) +// } +// +// c := NewClient("") +// err := c.JoinServer(fmt.Sprintf(":%v", port), test.account.Login, test.account.Password) +// if err != nil { +// t.Errorf("login failed: %v", err) +// } +// +// if err := c.Send(test.request); err != nil { +// t.Errorf("%v", err) +// } +// +// tran := c.ReadTransactions()[0] +// for _, want := range test.want.Fields { +// got := tran.GetField(want.Uint16ID()) +// if bytes.Compare(got.Data, want.Data) != 0 { +// t.Errorf("%v: field mismatch: want: %#v got: %#v", test.description, want.Data, got.Data) +// } +// } +// +// srv.DeleteUser(test.account.Login) +// +// if test.teardown != nil { +// test.teardown() +// } +// } +//} +// +//func TestDeleteUser(t *testing.T) { +// srv, port := StartTestServer() +// +// var tests = []transactionTest{ +// { +// description: "a deleteUser request from a user without the required access", +// account: Account{ +// Login: "test", +// Name: "unnamed", +// Password: "test", +// Access: []byte{0, 0, 0, 0, 0, 0, 0, 0}, +// }, +// request: NewTransaction( +// tranDeleteUser, 0, +// []Field{ +// NewField(fieldUserLogin, []byte(NegatedUserString([]byte("foo")))), +// }, +// ), +// want: Transaction{ +// Fields: []Field{ +// NewField(fieldError, []byte("You are not allowed to delete accounts.")), +// }, +// }, +// }, +// { +// description: "a valid deleteUser request", +// setup: func() { +// _ = srv.NewUser("foo", "foo", "foo", []byte{0, 0, 0, 0, 0, 0, 0, 0}) +// }, +// account: Account{ +// Login: "test", +// Name: "unnamed", +// Password: "test", +// Access: []byte{255, 255, 255, 255, 255, 255, 255, 255}, +// }, +// request: NewTransaction( +// tranDeleteUser, 0, +// []Field{ +// NewField(fieldUserLogin, []byte(NegatedUserString([]byte("foo")))), +// }, +// ), +// want: Transaction{ +// Fields: []Field{}, +// }, +// }, +// } +// +// for _, test := range tests { +// test.Setup(srv) +// +// c := NewClient("") +// err := c.JoinServer(fmt.Sprintf(":%v", port), test.account.Login, test.account.Password) +// if err != nil { +// t.Errorf("login failed: %v", err) +// } +// +// if err := c.Send(test.request); err != nil { +// t.Errorf("%v", err) +// } +// +// tran := c.ReadTransactions()[0] +// for _, want := range test.want.Fields { +// got := tran.GetField(want.Uint16ID()) +// if bytes.Compare(got.Data, want.Data) != 0 { +// t.Errorf("%v: field mismatch: want: %#v got: %#v", test.description, want.Data, got.Data) +// } +// } +// +// test.Teardown(srv) +// } +//} +// +//func TestDeleteFile(t *testing.T) { +// srv, port := StartTestServer() +// +// var tests = []transactionTest{ +// { +// description: "a request without the required access", +// account: Account{ +// Login: "test", +// Name: "unnamed", +// Password: "test", +// Access: []byte{0, 0, 0, 0, 0, 0, 0, 0}, +// }, +// request: NewTransaction( +// tranDeleteFile, 0, +// []Field{ +// NewField(fieldFileName, []byte("testFile")), +// NewField(fieldFilePath, []byte("")), +// }, +// ), +// want: Transaction{ +// Fields: []Field{}, +// }, +// }, +// { +// description: "a valid deleteFile request", +// setup: func() { +// _ = ioutil.WriteFile(srv.Config.FileRoot+"testFile", []byte{0x00}, 0666) +// }, +// account: Account{ +// Login: "test", +// Name: "unnamed", +// Password: "test", +// Access: []byte{255, 255, 255, 255, 255, 255, 255, 255}, +// }, +// request: NewTransaction( +// tranDeleteFile, 0, +// []Field{ +// NewField(fieldFileName, []byte("testFile")), +// NewField(fieldFilePath, []byte("")), +// }, +// ), +// want: Transaction{ +// Fields: []Field{}, +// }, +// }, +// { +// description: "an invalid request for a file that does not exist", +// account: Account{ +// Login: "test", +// Name: "unnamed", +// Password: "test", +// Access: []byte{255, 255, 255, 255, 255, 255, 255, 255}, +// }, +// request: NewTransaction( +// tranDeleteFile, 0, +// []Field{ +// NewField(fieldFileName, []byte("testFile")), +// NewField(fieldFilePath, []byte("")), +// }, +// ), +// want: Transaction{ +// Fields: []Field{ +// NewField(fieldError, []byte("Cannot delete file testFile because it does not exist or cannot be found.")), +// }, +// }, +// }, +// } +// +// for _, test := range tests { +// test.Setup(srv) +// +// c := NewClient("") +// +// if err := c.JoinServer(fmt.Sprintf(":%v", port), test.account.Login, test.account.Password); err != nil { +// t.Errorf("login failed: %v", err) +// } +// +// if err := c.Send(test.request); err != nil { +// t.Errorf("%v", err) +// } +// +// tran := c.ReadTransactions()[0] +// for _, want := range test.want.Fields { +// got := tran.GetField(want.Uint16ID()) +// if bytes.Compare(got.Data, want.Data) != 0 { +// t.Errorf("%v: field mismatch: want: %#v got: %#v", test.description, want.Data, got.Data) +// } +// } +// +// test.Teardown(srv) +// } +//} +// +//func Test_authorize(t *testing.T) { +// accessBitmap := big.NewInt(int64(0)) +// accessBitmap.SetBit(accessBitmap, accessCreateFolder, 1) +// fmt.Printf("%v %b %x\n", accessBitmap, accessBitmap, accessBitmap) +// fmt.Printf("%b\n", 0b10000) +// +// type args struct { +// access *[]byte +// reqAccess int +// } +// tests := []struct { +// name string +// args args +// want bool +// }{ +// { +// name: "fooz", +// args: args{ +// access: &[]byte{4, 0, 0, 0, 0, 0, 0, 0x02}, +// reqAccess: accessDownloadFile, +// }, +// want: true, +// }, +// } +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// if got := authorize(tt.args.access, tt.args.reqAccess); got != tt.want { +// t.Errorf("authorize() = %v, want %v", got, tt.want) +// } +// }) +// } +//} diff --git a/hotline/stats.go b/hotline/stats.go new file mode 100644 index 0000000..dd8ea1d --- /dev/null +++ b/hotline/stats.go @@ -0,0 +1,33 @@ +package hotline + +import ( + "fmt" + "time" +) + +type Stats struct { + LoginCount int `yaml:"login count"` + StartTime time.Time `yaml:"start time"` + Uptime time.Duration `yaml:"uptime"` +} + +func (s *Stats) String() string { + template := ` +Server Stats: + Start Time: %v + Uptime: %s + Login Count: %v +` + d := time.Since(s.StartTime) + d = d.Round(time.Minute) + h := d / time.Hour + d -= h * time.Hour + m := d / time.Minute + + return fmt.Sprintf( + template, + s.StartTime.Format(time.RFC1123Z), + fmt.Sprintf("%02d:%02d", h, m), + s.LoginCount, + ) +} diff --git a/hotline/test/config/Agreement.txt b/hotline/test/config/Agreement.txt new file mode 100644 index 0000000..2a3bdb7 --- /dev/null +++ b/hotline/test/config/Agreement.txt @@ -0,0 +1 @@ +This is a server agreement. Say you agree. \ No newline at end of file diff --git a/hotline/test/config/Files/test/testfile-1k b/hotline/test/config/Files/test/testfile-1k new file mode 100644 index 0000000..31758a0 Binary files /dev/null and b/hotline/test/config/Files/test/testfile-1k differ diff --git a/hotline/test/config/Files/test/testfile-5k b/hotline/test/config/Files/test/testfile-5k new file mode 100644 index 0000000..c889187 Binary files /dev/null and b/hotline/test/config/Files/test/testfile-5k differ diff --git a/hotline/test/config/Files/testdir/some-nested-file.txt b/hotline/test/config/Files/testdir/some-nested-file.txt new file mode 100644 index 0000000..e69de29 diff --git a/hotline/test/config/Files/testfile.sit b/hotline/test/config/Files/testfile.sit new file mode 100644 index 0000000..8d1d542 --- /dev/null +++ b/hotline/test/config/Files/testfile.sit @@ -0,0 +1 @@ +nothing to see here \ No newline at end of file diff --git a/hotline/test/config/Files/testfile.txt b/hotline/test/config/Files/testfile.txt new file mode 100644 index 0000000..f0607d4 --- /dev/null +++ b/hotline/test/config/Files/testfile.txt @@ -0,0 +1 @@ +Hello, I'm a test file! \ No newline at end of file diff --git a/hotline/test/config/MessageBoard.txt b/hotline/test/config/MessageBoard.txt new file mode 100644 index 0000000..1a2f57a --- /dev/null +++ b/hotline/test/config/MessageBoard.txt @@ -0,0 +1 @@ +From test (Dec31 15:55): Test News Post __________________________________________________________ From test (Dec31 15:54): Test News Post __________________________________________________________ From test (Dec31 15:53): Test News Post __________________________________________________________ From test (Dec31 15:52): Test News Post __________________________________________________________ From test (Dec31 15:50): Test News Post __________________________________________________________ From test (Dec31 15:50): Test News Post __________________________________________________________ From test (Dec31 15:50): Test News Post __________________________________________________________ From test (Dec31 15:49): Test News Post __________________________________________________________ From test (Dec31 15:47): Test News Post __________________________________________________________ From test (Dec31 15:47): Test News Post __________________________________________________________ From test (Dec31 15:47): Test News Post __________________________________________________________ From test (Dec31 15:44): Test News Post __________________________________________________________ From test (Dec31 15:44): Test News Post __________________________________________________________ From test (Dec31 15:43): Test News Post __________________________________________________________ From test (Dec31 15:43): Test News Post __________________________________________________________ From test (Dec31 15:29): Test News Post __________________________________________________________ From test (Dec31 15:23): Test News Post __________________________________________________________ From test (Dec31 15:18): Test News Post __________________________________________________________ From test (Dec31 15:13): Test News Post __________________________________________________________ From test (Dec31 14:23): Test News Post __________________________________________________________ From test (Dec31 14:21): Test News Post __________________________________________________________ From test (Dec31 14:20): Test News Post __________________________________________________________ From test (Dec31 14:20): Test News Post __________________________________________________________ From test (Dec31 14:19): Test News Post __________________________________________________________ From test (Dec31 14:18): Test News Post __________________________________________________________ From test (Dec31 14:14): Test News Post __________________________________________________________ From test (Dec31 14:14): Test News Post __________________________________________________________ From test (Dec31 14:13): Test News Post __________________________________________________________ From test (Dec31 14:13): Test News Post __________________________________________________________ From test (Dec31 14:12): Test News Post __________________________________________________________ From test (Dec31 14:10): Test News Post __________________________________________________________ From test (Dec31 14:10): Test News Post __________________________________________________________ From test (Dec31 14:10): Test News Post __________________________________________________________ From test (Dec31 14:9): Test News Post __________________________________________________________ From test (Dec31 14:9): Test News Post __________________________________________________________ From test (Dec31 14:9): Test News Post __________________________________________________________ From test (Dec31 14:2): Test News Post __________________________________________________________ From test (Dec31 14:1): Test News Post __________________________________________________________ From test (Dec31 14:1): Test News Post __________________________________________________________ From test (Dec31 13:59): Test News Post __________________________________________________________ From test (Dec31 13:13): Test News Post __________________________________________________________ From test (Dec31 10:58): Test News Post __________________________________________________________ From test (Dec08 14:39): Test News Post __________________________________________________________ From test (Dec08 9:52): Test News Post __________________________________________________________ From test (Dec08 7:59): Test News Post __________________________________________________________ From test (Dec08 7:59): Test News Post __________________________________________________________ From test (Dec07 11:44): Test News Post __________________________________________________________ From test (Dec07 11:44): Test News Post __________________________________________________________ From test (Dec07 11:44): Test News Post __________________________________________________________ From test (Dec07 11:43): Test News Post __________________________________________________________ From test (Dec07 11:30): Test News Post __________________________________________________________ From test (Dec07 11:29): Test News Post __________________________________________________________ From test (Dec07 11:29): Test News Post __________________________________________________________ From test (Dec07 10:13): Test News Post __________________________________________________________ From test (Dec07 10:13): Test News Post __________________________________________________________ From test (Dec07 10:12): Test News Post __________________________________________________________ From test (Dec07 10:11): Test News Post __________________________________________________________ From test (Dec07 9:19): Test News Post __________________________________________________________ From test (Dec05 17:9): Test News Post __________________________________________________________ From test (Dec03 10:58): Test News Post __________________________________________________________ From test (Dec02 17:19): Test News Post __________________________________________________________ From test (Dec02 17:18): Test News Post __________________________________________________________ From test (Dec02 15:38): Test News Post __________________________________________________________ From test (Dec02 15:38): Test News Post __________________________________________________________ From test (Dec02 15:34): Test News Post __________________________________________________________ From test (Dec02 15:27): Test News Post __________________________________________________________ From test (Dec02 15:27): Test News Post __________________________________________________________ From test (Dec02 15:18): Test News Post __________________________________________________________ From test (Dec02 15:17): Test News Post __________________________________________________________ From test (Dec02 15:16): Test News Post __________________________________________________________ From test (Dec02 14:56): Test News Post __________________________________________________________ From test (Dec02 14:55): Test News Post __________________________________________________________ From test (Dec02 14:55): Test News Post __________________________________________________________ From test (Dec02 14:55): Test News Post __________________________________________________________ From test (Dec02 14:54): Test News Post __________________________________________________________ From test (Dec02 14:54): Test News Post __________________________________________________________ From test (Dec02 14:53): Test News Post __________________________________________________________ From test (Dec02 14:50): Test News Post __________________________________________________________ From test (Dec02 14:49): Test News Post __________________________________________________________ From test (Dec02 14:49): Test News Post __________________________________________________________ From test (Dec02 14:47): Test News Post __________________________________________________________ From test (Dec02 14:34): Test News Post __________________________________________________________ From test (Dec02 14:34): Test News Post __________________________________________________________ From test (Dec02 14:26): Test News Post __________________________________________________________ From test (Dec02 14:23): Test News Post __________________________________________________________ From test (Dec02 14:22): Test News Post __________________________________________________________ From test (Dec02 14:21): Test News Post __________________________________________________________ From test (Dec02 14:17): Test News Post __________________________________________________________ From test (Dec02 14:15): Test News Post __________________________________________________________ From test (Dec02 14:14): Test News Post __________________________________________________________ From test (Dec02 14:13): Test News Post __________________________________________________________ From test (Dec02 14:13): Test News Post __________________________________________________________ From test (Dec02 14:13): Test News Post __________________________________________________________ From test (Dec02 14:13): Test News Post __________________________________________________________ From test (Dec02 14:13): Test News Post __________________________________________________________ From test (Dec02 14:12): Test News Post __________________________________________________________ From test (Dec02 14:12): Test News Post __________________________________________________________ From test (Dec01 13:58): Test News Post __________________________________________________________ From test (Dec01 13:54): Test News Post __________________________________________________________ From test (Dec01 13:34): Test News Post __________________________________________________________ From test (Dec01 12:26): Test News Post __________________________________________________________ From test (Dec01 12:26): Test News Post __________________________________________________________ From test (Dec01 12:26): Test News Post __________________________________________________________ From test (Dec01 12:26): Test News Post __________________________________________________________ From test (Dec01 12:16): Test News Post __________________________________________________________ From test (Dec01 12:16): Test News Post __________________________________________________________ From test (Dec01 12:16): Test News Post __________________________________________________________ From test (Dec01 12:16): Test News Post __________________________________________________________ From test (Dec01 12:16): Test News Post __________________________________________________________ From test (Dec01 12:16): Test News Post __________________________________________________________ From test (Dec01 12:16): Test News Post __________________________________________________________ From test (Dec01 12:16): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:14): Test News Post __________________________________________________________ From test (Dec01 12:14): Test News Post __________________________________________________________ From test (Dec01 12:14): Test News Post __________________________________________________________ From test (Dec01 12:14): Test News Post __________________________________________________________ From test (Dec01 12:14): Test News Post __________________________________________________________ From test (Dec01 12:14): Test News Post __________________________________________________________ From test (Dec01 12:14): Test News Post __________________________________________________________ From test (Dec01 12:14): Test News Post __________________________________________________________ From test (Dec01 12:11): Test News Post __________________________________________________________ From test (Dec01 12:11): Test News Post __________________________________________________________ From test (Dec01 12:11): Test News Post __________________________________________________________ From test (Dec01 12:11): Test News Post __________________________________________________________ From test (Dec01 12:11): Test News Post __________________________________________________________ From test (Dec01 12:11): Test News Post __________________________________________________________ From test (Dec01 12:11): Test News Post __________________________________________________________ From test (Dec01 12:3): Test News Post __________________________________________________________ From test (Dec01 12:3): Test News Post __________________________________________________________ From test (Dec01 12:3): Test News Post __________________________________________________________ From test (Dec01 11:55): Test News Post __________________________________________________________ From test (Dec01 11:55): Test News Post __________________________________________________________ From test (Dec01 11:54): Test News Post __________________________________________________________ From test (Dec01 11:53): Test News Post __________________________________________________________ From test (Dec01 11:53): Test News Post __________________________________________________________ From test (Dec01 11:49): Test News Post __________________________________________________________ From test (Dec01 11:49): Test News Post __________________________________________________________ From test (Dec01 11:49): Test News Post __________________________________________________________ From test (Dec01 11:47): Test News Post __________________________________________________________ From test (Dec01 11:47): Test News Post __________________________________________________________ From test (Dec01 11:46): Test News Post __________________________________________________________ From test (Dec01 11:46): Test News Post __________________________________________________________ From test (Dec01 11:45): Test News Post __________________________________________________________ From test (Dec01 11:45): Test News Post __________________________________________________________ From test (Dec01 11:44): Test News Post __________________________________________________________ From test (Dec01 11:20): Test News Post __________________________________________________________ From test (Dec01 11:18): Test News Post __________________________________________________________ From test (Dec01 11:14): Test News Post __________________________________________________________ From test (Dec01 10:54): Test News Post __________________________________________________________ From test (Dec01 10:48): Test News Post __________________________________________________________ From test (Dec01 10:48): Test News Post __________________________________________________________ From test (Dec01 10:48): Test News Post __________________________________________________________ From test (Dec01 10:45): Test News Post __________________________________________________________ From test (Dec01 10:31): Test News Post __________________________________________________________ From test (Dec01 10:30): Test News Post __________________________________________________________ From test (Dec01 10:29): Test News Post __________________________________________________________ From test (Dec01 10:18): Test News Post __________________________________________________________ From test (Dec01 10:18): Test News Post __________________________________________________________ From test (Dec01 10:18): Test News Post __________________________________________________________ From test (Dec01 10:18): Test News Post __________________________________________________________ From test (Dec01 10:18): Test News Post __________________________________________________________ From test (Dec01 10:18): Test News Post __________________________________________________________ From test (Dec01 10:15): Test News Post __________________________________________________________ From test (Dec01 10:15): Test News Post __________________________________________________________ From test (Dec01 10:15): Test News Post __________________________________________________________ From test (Dec01 10:15): Test News Post __________________________________________________________ From test (Dec01 10:13): Test News Post __________________________________________________________ From test (Nov30 14:29): Test News Post __________________________________________________________ From test (Nov30 14:29): Test News Post __________________________________________________________ From test (Nov30 14:29): Test News Post __________________________________________________________ From test (Nov30 14:29): Test News Post __________________________________________________________ From test (Nov30 14:29): Test News Post __________________________________________________________ From test (Nov30 14:29): Test News Post __________________________________________________________ From test (Nov30 14:29): Test News Post __________________________________________________________ From test (Nov30 14:28): Test News Post __________________________________________________________ From test (Nov30 14:19): Test News Post __________________________________________________________ From test (Nov30 14:19): Test News Post __________________________________________________________ From test (Nov30 14:19): Test News Post __________________________________________________________ From test (Nov30 14:19): Test News Post __________________________________________________________ From (Nov30 11:42): Test News Post __________________________________________________________ From test (Nov30 11:22): Test News Post __________________________________________________________ From test (Nov30 11:22): Test News Post __________________________________________________________ From test (Nov30 11:21): Test News Post __________________________________________________________ From test (Nov30 11:18): Test News Post __________________________________________________________ From test (Nov30 11:18): Test News Post __________________________________________________________ From test (Nov30 11:18): Test News Post __________________________________________________________ From test (Nov30 11:18): Test News Post __________________________________________________________ From test (Nov30 11:17): Test News Post __________________________________________________________ From test (Nov30 11:15): Test News Post __________________________________________________________ From test (Nov30 11:13): Test News Post __________________________________________________________ From test (Nov30 11:11): Test News Post __________________________________________________________ From test (Nov30 11:11): Test News Post __________________________________________________________ From test (Nov30 11:10): Test News Post __________________________________________________________ From test (Nov30 11:8): Test News Post __________________________________________________________ From test (Nov30 11:5): Test News Post __________________________________________________________ From test (Nov30 11:2): Test News Post __________________________________________________________ From test (Nov30 11:2): Test News Post __________________________________________________________ From test (Nov30 11:1): Test News Post __________________________________________________________ From test (Nov30 11:1): Test News Post __________________________________________________________ From test (Nov30 11:1): Test News Post __________________________________________________________ From test (Nov30 11:1): Test News Post __________________________________________________________ From test (Nov30 11:1): Test News Post __________________________________________________________ From test (Nov30 11:1): Test News Post __________________________________________________________ From test (Nov30 11:0): Test News Post __________________________________________________________ From test (Nov30 10:49): Test News Post __________________________________________________________ From test (Nov30 10:49): Test News Post __________________________________________________________ From test (Nov30 10:49): Test News Post __________________________________________________________ From test (Nov30 10:49): Test News Post __________________________________________________________ From test (Nov30 10:49): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:45): Test News Post __________________________________________________________ From test (Nov30 10:44): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:36): Test News Post __________________________________________________________ From test (Nov30 10:36): Test News Post __________________________________________________________ From test (Nov30 10:36): Test News Post __________________________________________________________ From test (Nov30 10:36): Test News Post __________________________________________________________ From test (Nov30 10:36): Test News Post __________________________________________________________ From test (Nov30 10:36): Test News Post __________________________________________________________ From test (Nov30 10:36): Test News Post __________________________________________________________ From test (Nov30 10:36): Test News Post __________________________________________________________ From test (Nov30 10:36): Test News Post __________________________________________________________ From test (Nov30 10:34): Test News Post __________________________________________________________ From test (Nov30 10:34): Test News Post __________________________________________________________ From test (Nov30 10:34): Test News Post __________________________________________________________ From test (Nov30 10:34): Test News Post __________________________________________________________ From test (Nov30 10:34): Test News Post __________________________________________________________ From test (Nov30 10:34): Test News Post __________________________________________________________ From test (Nov30 10:34): Test News Post __________________________________________________________ From test (Nov30 10:34): Test News Post __________________________________________________________ From test (Nov30 10:33): Test News Post __________________________________________________________ From test (Nov30 10:33): Test News Post __________________________________________________________ From test (Nov30 10:33): Test News Post __________________________________________________________ From test (Nov30 10:33): Test News Post __________________________________________________________ From test (Nov30 10:31): Test News Post __________________________________________________________ From test (Nov30 10:29): Test News Post __________________________________________________________ From test (Nov30 10:25): Test News Post __________________________________________________________ From test (Nov30 10:25): Test News Post __________________________________________________________ From test (Nov30 10:23): Test News Post __________________________________________________________ From test (Nov30 10:23): Test News Post __________________________________________________________ From test (Nov30 10:22): Test News Post __________________________________________________________ From test (Nov30 10:21): Test News Post __________________________________________________________ From test (Nov30 10:20): Test News Post __________________________________________________________ From test (Nov30 10:19): Test News Post __________________________________________________________ From test (Nov30 10:19): Test News Post __________________________________________________________ From test (Nov30 10:19): Test News Post __________________________________________________________ From test (Nov30 10:12): Test News Post __________________________________________________________ From test (Nov30 9:59): Test News Post __________________________________________________________ From test (Nov30 9:58): Test News Post __________________________________________________________ From test (Nov30 9:58): Test News Post __________________________________________________________ From test (Nov30 9:58): Test News Post __________________________________________________________ From test (Nov30 9:58): Test News Post __________________________________________________________ From test (Nov30 9:52): Test News Post __________________________________________________________ From test (Nov30 9:52): Test News Post __________________________________________________________ From test (Nov30 9:52): Test News Post __________________________________________________________ From test (Nov30 9:52): Test News Post __________________________________________________________ From test (Nov30 9:52): Test News Post __________________________________________________________ From test (Nov30 9:52): Test News Post __________________________________________________________ From test (Nov30 9:52): Test News Post __________________________________________________________ From test (Nov30 9:51): Test News Post __________________________________________________________ From test (Nov30 9:49): Test News Post __________________________________________________________ From test (Nov30 9:48): Test News Post __________________________________________________________ From test (Nov29 17:36): Test News Post __________________________________________________________ From test (Nov29 17:35): Test News Post __________________________________________________________ From test (Nov29 17:34): Test News Post __________________________________________________________ From test (Nov29 17:33): Test News Post __________________________________________________________ From test (Nov29 17:33): Test News Post __________________________________________________________ From test (Nov29 17:33): Test News Post __________________________________________________________ From test (Nov29 17:32): Test News Post __________________________________________________________ From test (Nov29 17:24): Test News Post __________________________________________________________ From test (Nov29 17:24): Test News Post __________________________________________________________ From test (Nov29 17:24): Test News Post __________________________________________________________ From test (Nov29 17:23): Test News Post __________________________________________________________ From test (Nov29 17:23): Test News Post __________________________________________________________ From test (Nov29 17:22): Test News Post __________________________________________________________ From test (Nov29 17:22): Test News Post __________________________________________________________ From test (Nov29 17:22): Test News Post __________________________________________________________ From test (Nov29 17:22): Test News Post __________________________________________________________ From test (Nov29 17:22): Test News Post __________________________________________________________ From test (Nov29 17:13): Test News Post __________________________________________________________ From test (Nov29 17:11): Test News Post __________________________________________________________ From test (Nov29 17:11): Test News Post __________________________________________________________ From test (Nov29 17:11): Test News Post __________________________________________________________ From test (Nov29 17:9): Test News Post __________________________________________________________ From test (Nov29 17:8): Test News Post __________________________________________________________ From test (Nov29 17:8): Test News Post __________________________________________________________ From test (Nov29 17:7): Test News Post __________________________________________________________ From test (Nov29 17:5): Test News Post __________________________________________________________ From test (Nov29 16:53): Test News Post __________________________________________________________ From test (Nov29 16:52): Test News Post __________________________________________________________ From test (Nov29 16:50): Test News Post __________________________________________________________ From test (Nov29 16:50): Test News Post __________________________________________________________ From test (Nov29 16:46): Test News Post __________________________________________________________ From test (Nov29 16:29): Test News Post __________________________________________________________ From test (Nov29 16:29): Test News Post __________________________________________________________ From test (Nov29 16:28): Test News Post __________________________________________________________ From test (Nov29 16:22): Test News Post __________________________________________________________ From test (Nov29 10:55): Test News Post __________________________________________________________ From test (Nov29 10:24): Test News Post __________________________________________________________ From test (Nov28 16:6): Test News Post __________________________________________________________ From test (Nov28 16:6): Test News Post __________________________________________________________ From test (Nov28 15:46): Test News Post __________________________________________________________ From test (Nov28 15:46): Test News Post __________________________________________________________ From test (Nov28 15:46): Test News Post __________________________________________________________ From test (Nov28 15:46): Test News Post __________________________________________________________ From test (Nov28 15:46): Test News Post __________________________________________________________ From test (Nov28 15:46): Test News Post __________________________________________________________ From test (Nov28 15:45): Test News Post __________________________________________________________ From test (Nov28 15:44): Test News Post __________________________________________________________ From test (Nov28 15:44): Test News Post __________________________________________________________ From test (Nov28 15:43): Test News Post __________________________________________________________ From test (Nov28 15:19): Test News Post __________________________________________________________ From test (Nov28 15:19): Test News Post __________________________________________________________ From test (Nov28 15:18): Test News Post __________________________________________________________ From test (Nov28 15:18): Test News Post __________________________________________________________ From test (Nov28 15:13): Test News Post __________________________________________________________ From test (Nov28 15:13): Test News Post __________________________________________________________ From test (Nov28 15:12): Test News Post __________________________________________________________ From test (Nov28 14:24): Test News Post __________________________________________________________ From test (Nov28 14:13): Test News Post __________________________________________________________ From test (Nov28 14:13): Test News Post __________________________________________________________ From test (Nov28 14:12): Test News Post __________________________________________________________ From test (Nov28 14:11): Test News Post __________________________________________________________ From test (Nov28 14:10): Test News Post __________________________________________________________ From test (Nov28 14:10): Test News Post __________________________________________________________ From test (Nov28 14:10): Test News Post __________________________________________________________ From test (Nov28 14:10): Test News Post __________________________________________________________ From test (Nov28 14:10): Test News Post __________________________________________________________ From test (Nov28 14:9): Test News Post __________________________________________________________ From test (Nov28 14:9): Test News Post __________________________________________________________ From test (Nov28 14:7): Test News Post __________________________________________________________ From test (Nov28 14:7): Test News Post __________________________________________________________ From test (Nov28 14:6): Test News Post __________________________________________________________ From test (Nov28 14:6): Test News Post __________________________________________________________ From test (Nov28 14:5): Test News Post __________________________________________________________ From test (Nov28 14:4): Test News Post __________________________________________________________ From test (Nov28 14:4): Test News Post __________________________________________________________ From test (Nov28 14:4): Test News Post __________________________________________________________ From test (Nov28 14:3): Test News Post __________________________________________________________ From test (Nov28 14:2): Test News Post __________________________________________________________ From test (Nov28 14:2): Test News Post __________________________________________________________ From test (Nov28 14:1): Test News Post __________________________________________________________ From test (Nov28 14:1): Test News Post __________________________________________________________ From test (Nov28 14:1): Test News Post __________________________________________________________ From test (Nov28 14:0): Test News Post __________________________________________________________ From test (Nov28 14:0): Test News Post __________________________________________________________ From test (Nov28 13:56): Test News Post __________________________________________________________ From test (Nov28 13:56): Test News Post __________________________________________________________ From test (Nov28 13:56): Test News Post __________________________________________________________ From test (Nov28 13:55): Test News Post __________________________________________________________ From test (Nov28 13:54): Test News Post __________________________________________________________ From test (Nov28 13:51): Test News Post __________________________________________________________ From test (Nov28 13:50): Test News Post __________________________________________________________ From test (Nov28 13:45): Test News Post __________________________________________________________ From test (Nov28 13:37): Test News Post __________________________________________________________ From test (Nov28 12:37): Test News Post __________________________________________________________ From test (Nov28 12:37): Test News Post __________________________________________________________ From test (Nov28 12:37): Test News Post __________________________________________________________ From test (Nov28 12:37): Test News Post __________________________________________________________ From test (Nov28 12:34): Test News Post __________________________________________________________ From test (Nov28 12:33): Test News Post __________________________________________________________ From test (Nov28 12:33): Test News Post __________________________________________________________ From test (Nov28 12:33): Test News Post __________________________________________________________ From test (Nov28 12:32): Test News Post __________________________________________________________ From test (Nov28 12:32): Test News Post __________________________________________________________ From test (Nov28 12:21): Test News Post __________________________________________________________ From test (Nov28 12:21): Test News Post __________________________________________________________ From test (Nov28 12:21): Test News Post __________________________________________________________ From test (Nov28 12:21): Test News Post __________________________________________________________ From test (Nov28 12:20): Test News Post __________________________________________________________ From test (Nov28 12:19): Test News Post __________________________________________________________ From test (Nov28 12:19): Test News Post __________________________________________________________ From test (Nov28 12:4): Test News Post __________________________________________________________ From test (Nov28 12:1): Test News Post __________________________________________________________ From test (Nov28 11:58): Test News Post __________________________________________________________ From test (Nov28 11:58): Test News Post __________________________________________________________ From test (Nov28 11:55): Test News Post __________________________________________________________ From test (Nov28 11:54): Test News Post __________________________________________________________ From test (Nov28 11:54): Test News Post __________________________________________________________ From test (Nov28 11:52): Test News Post __________________________________________________________ From test (Nov28 11:51): Test News Post __________________________________________________________ From test (Nov28 11:48): Test News Post __________________________________________________________ From test (Nov28 11:48): Test News Post __________________________________________________________ From test (Nov28 11:47): Test News Post __________________________________________________________ From test (Nov28 11:47): Test News Post __________________________________________________________ From test (Nov28 11:47): Test News Post __________________________________________________________ From test (Nov28 11:46): Test News Post __________________________________________________________ From test (Nov28 11:46): Test News Post __________________________________________________________ From test (Nov28 11:46): Test News Post __________________________________________________________ From test (Nov28 11:42): Test News Post __________________________________________________________ From test (Nov28 11:42): Test News Post __________________________________________________________ From test (Nov28 11:38): Test News Post __________________________________________________________ From test (Nov28 11:38): Test News Post __________________________________________________________ From test (Nov28 11:37): Test News Post __________________________________________________________ From test (Nov28 11:31): Test News Post __________________________________________________________ From test (Nov28 11:31): Test News Post __________________________________________________________ From test (Nov28 11:31): Test News Post __________________________________________________________ From test (Nov28 11:31): Test News Post __________________________________________________________ From test (Nov28 11:30): Test News Post __________________________________________________________ From test (Nov28 11:30): Test News Post __________________________________________________________ From test (Nov28 11:30): Test News Post __________________________________________________________ From test (Nov28 11:30): Test News Post __________________________________________________________ From test (Nov28 11:30): Test News Post __________________________________________________________ From test (Nov28 11:28): Test News Post __________________________________________________________ From test (Nov28 11:28): Test News Post __________________________________________________________ From test (Nov28 11:28): Test News Post __________________________________________________________ From test (Nov28 11:27): Test News Post __________________________________________________________ From test (Nov28 11:27): Test News Post __________________________________________________________ From test (Nov28 11:27): Test News Post __________________________________________________________ From test (Nov28 11:26): Test News Post __________________________________________________________ From test (Nov28 11:25): Test News Post __________________________________________________________ From test (Nov28 11:24): Test News Post __________________________________________________________ From test (Nov28 11:24): Test News Post __________________________________________________________ From test (Nov28 11:23): Test News Post __________________________________________________________ From test (Nov28 11:19): Test News Post __________________________________________________________ From test (Nov28 11:15): Test News Post __________________________________________________________ From test (Nov28 11:15): Test News Post __________________________________________________________ From test (Nov28 11:9): Test News Post __________________________________________________________ From test (Nov28 11:9): Test News Post __________________________________________________________ From test (Nov28 11:8): Test News Post __________________________________________________________ From test (Nov28 11:8): Test News Post __________________________________________________________ From test (Nov28 11:7): Test News Post __________________________________________________________ From test (Nov28 11:7): Test News Post __________________________________________________________ From test (Nov28 10:58): Test News Post __________________________________________________________ From test (Nov28 10:58): Test News Post __________________________________________________________ From test (Nov28 10:58): Test News Post __________________________________________________________ From test (Nov28 10:57): Test News Post __________________________________________________________ From test (Nov28 10:57): Test News Post __________________________________________________________ From test (Nov28 10:54): Test News Post __________________________________________________________ From test (Nov28 10:54): Test News Post __________________________________________________________ From test (Nov28 10:54): Test News Post __________________________________________________________ From test (Nov28 10:53): Test News Post __________________________________________________________ From test (Nov28 10:52): Test News Post __________________________________________________________ From test (Nov28 10:48): Test News Post __________________________________________________________ From test (Nov28 10:47): Test News Post __________________________________________________________ From test (Nov28 10:47): Test News Post __________________________________________________________ From test (Nov28 10:47): Test News Post __________________________________________________________ From test (Nov28 10:47): Test News Post __________________________________________________________ From test (Nov28 10:40): Test News Post __________________________________________________________ From test (Jul12 17:20): Test News Post __________________________________________________________ From test (Jul12 17:20): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:18): Test News Post __________________________________________________________ From test (Jul12 17:18): Test News Post __________________________________________________________ From test (Jul12 17:18): Test News Post __________________________________________________________ From test (Jul12 17:14): Test News Post __________________________________________________________ From test (Jul12 17:13): Test News Post __________________________________________________________ From test (Jul12 17:12): Test News Post __________________________________________________________ From test (Jul12 17:12): Test News Post __________________________________________________________ From test (Jul12 17:12): Test News Post __________________________________________________________ From test (Jul12 17:12): Test News Post __________________________________________________________ From test (Jul12 16:41): Test News Post __________________________________________________________ From test (Jul12 16:29): Test News Post __________________________________________________________ From test (Jul12 16:29): Test News Post __________________________________________________________ From test (Jul12 16:29): Test News Post __________________________________________________________ From test (Jul12 16:28): Test News Post __________________________________________________________ From test (Jul12 16:27): Test News Post __________________________________________________________ From test (Jul12 16:27): Test News Post __________________________________________________________ From test (Jul12 16:26): Test News Post __________________________________________________________ From test (Jul12 16:25): Test News Post __________________________________________________________ From test (Jul12 16:24): Test News Post __________________________________________________________ From test (Jul12 16:13): Test News Post __________________________________________________________ From test (Jul12 16:12): Test News Post __________________________________________________________ From test (Jul12 16:11): Test News Post __________________________________________________________ From test (Jul12 16:10): Test News Post __________________________________________________________ From test (Jul12 16:10): Test News Post __________________________________________________________ From test (Jul12 16:0): Test News Post __________________________________________________________ From test (Jul12 15:59): Test News Post __________________________________________________________ From test (Jul12 15:58): Test News Post __________________________________________________________ From test (Jul12 15:54): Test News Post __________________________________________________________ From test (Jul12 15:53): Test News Post __________________________________________________________ From test (Jul12 15:51): Test News Post __________________________________________________________ From test (Jul12 15:48): Test News Post __________________________________________________________ From test (Jul12 15:47): Test News Post __________________________________________________________ From test (Jul12 15:38): Test News Post __________________________________________________________ From test (Jul12 15:22): Test News Post __________________________________________________________ From test (Jul12 11:36): Test News Post __________________________________________________________ From test (Jul12 11:35): Test News Post __________________________________________________________ From test (Jul12 11:31): Test News Post __________________________________________________________ From test (Jul12 11:19): Test News Post __________________________________________________________ From test (Jul12 11:19): Test News Post __________________________________________________________ From test (Jul12 11:19): Test News Post __________________________________________________________ From test (Jul12 11:18): Test News Post __________________________________________________________ From test (Jul12 10:58): Test News Post __________________________________________________________ From test (Jul12 10:52): Test News Post __________________________________________________________ From test (Jul12 10:52): Test News Post __________________________________________________________ From test (Jul12 10:51): Test News Post __________________________________________________________ From test (Jul12 10:51): Test News Post __________________________________________________________ From test (Jul12 10:51): Test News Post __________________________________________________________ From test (Jul12 10:51): Test News Post __________________________________________________________ From test (Jul12 10:50): Test News Post __________________________________________________________ From test (Jul12 10:47): Test News Post __________________________________________________________ From test (Jul11 13:25): Test News Post __________________________________________________________ From test (Jul01 17:25): Test News Post __________________________________________________________ From test (Jul01 17:25): Test News Post __________________________________________________________ From test (Jul01 9:51): Test News Post __________________________________________________________ From test (Jul01 9:51): Test News Post __________________________________________________________ From test (Jul01 9:51): Test News Post __________________________________________________________ From test (Jul01 9:51): Test News Post __________________________________________________________ From test (Jul01 9:50): Test News Post __________________________________________________________ From test (Jul01 9:49): Test News Post __________________________________________________________ From test (Jul01 9:49): Test News Post __________________________________________________________ From test (Jul01 9:49): Test News Post __________________________________________________________ From test (Jul01 9:45): Test News Post __________________________________________________________ \ No newline at end of file diff --git a/hotline/test/config/ThreadedNews.yaml b/hotline/test/config/ThreadedNews.yaml new file mode 100644 index 0000000..9f3fd65 --- /dev/null +++ b/hotline/test/config/ThreadedNews.yaml @@ -0,0 +1,232 @@ +Categories: + TestBundle: + Type: + - 0 + - 2 + Name: TestBundle + Articles: {} + SubCats: + NestedBundle: + Type: + - 0 + - 2 + Name: NestedBundle + Articles: {} + SubCats: + NestedCat: + Type: + - 0 + - 3 + Name: NestedCat + Articles: {} + SubCats: {} + count: [] + addsn: [] + deletesn: [] + guid: [] + count: [] + addsn: [] + deletesn: [] + guid: [] + TestSubCat: + Type: + - 0 + - 3 + Name: TestSubCat + Articles: + 1: + Title: SubCatArt + Poster: Halcyon 1.9.2 + Date: + - 7 + - 228 + - 0 + - 0 + - 0 + - 254 + - 252 + - 246 + PrevArt: + - 0 + - 0 + - 0 + - 0 + NextArt: + - 0 + - 0 + - 0 + - 0 + ParentArt: + - 0 + - 0 + - 0 + - 0 + FirstChildArtArt: + - 0 + - 0 + - 0 + - 0 + DataFlav: + - 116 + - 101 + - 120 + - 116 + - 47 + - 112 + - 108 + - 97 + - 105 + - 110 + Data: I'm an article in a subcategory! + SubCats: {} + count: [] + addsn: [] + deletesn: [] + guid: [] + count: [] + addsn: [] + deletesn: [] + guid: [] + TestCat: + Type: + - 0 + - 3 + Name: TestCat + Articles: + 1: + Title: TestArt + Poster: Halcyon 1.9.2 + Date: + - 7 + - 228 + - 0 + - 0 + - 0 + - 254 + - 252 + - 204 + PrevArt: + - 0 + - 0 + - 0 + - 0 + NextArt: + - 0 + - 0 + - 0 + - 2 + ParentArt: + - 0 + - 0 + - 0 + - 0 + FirstChildArtArt: + - 0 + - 0 + - 0 + - 2 + DataFlav: + - 116 + - 101 + - 120 + - 116 + - 47 + - 112 + - 108 + - 97 + - 105 + - 110 + Data: TestArt Body + 2: + Title: 'Re: TestArt' + Poster: Halcyon 1.9.2 + Date: + - 7 + - 228 + - 0 + - 0 + - 0 + - 254 + - 252 + - 216 + PrevArt: + - 0 + - 0 + - 0 + - 1 + NextArt: + - 0 + - 0 + - 0 + - 3 + ParentArt: + - 0 + - 0 + - 0 + - 1 + FirstChildArtArt: + - 0 + - 0 + - 0 + - 0 + DataFlav: + - 116 + - 101 + - 120 + - 116 + - 47 + - 112 + - 108 + - 97 + - 105 + - 110 + Data: I'm a reply + 3: + Title: TestArt 2 + Poster: Halcyon 1.9.2 + Date: + - 7 + - 228 + - 0 + - 0 + - 0 + - 254 + - 253 + - 6 + PrevArt: + - 0 + - 0 + - 0 + - 2 + NextArt: + - 0 + - 0 + - 0 + - 0 + ParentArt: + - 0 + - 0 + - 0 + - 0 + FirstChildArtArt: + - 0 + - 0 + - 0 + - 0 + DataFlav: + - 116 + - 101 + - 120 + - 116 + - 47 + - 112 + - 108 + - 97 + - 105 + - 110 + Data: Hello world + SubCats: {} + count: [] + addsn: [] + deletesn: [] + guid: [] diff --git a/hotline/test/config/Users/admin.yaml b/hotline/test/config/Users/admin.yaml new file mode 100644 index 0000000..1bf656b --- /dev/null +++ b/hotline/test/config/Users/admin.yaml @@ -0,0 +1,13 @@ +Login: admin +Name: admin +Password: $2a$04$2itGEYx8C1N5bsfRSoC9JuonS3I4YfnyVPZHLSwp7kEInRX0yoB.a +Access: +- 255 +- 255 +- 255 +- 255 +- 255 +- 255 +- 0 +- 0 + diff --git a/hotline/test/config/Users/guest.yaml b/hotline/test/config/Users/guest.yaml new file mode 100644 index 0000000..57117bd --- /dev/null +++ b/hotline/test/config/Users/guest.yaml @@ -0,0 +1,12 @@ +Login: guest +Name: guest +Password: $2a$04$9P/jgLn1fR9TjSoWL.rKxuN6g.1TSpf2o6Hw.aaRuBwrWIJNwsKkS +Access: +- 125 +- 240 +- 12 +- 239 +- 171 +- 128 +- 0 +- 0 diff --git a/hotline/test/config/config.yaml b/hotline/test/config/config.yaml new file mode 100644 index 0000000..cb7f15a --- /dev/null +++ b/hotline/test/config/config.yaml @@ -0,0 +1,7 @@ +Name: Halcyon's Test Server +Description: Experimental Hotline server +BannerID: 1 +FileRoot: conFiles/ +EnableTrackerRegistration: false +Trackers: + - hltracker.com:5499 \ No newline at end of file diff --git a/hotline/tracker.go b/hotline/tracker.go new file mode 100644 index 0000000..97ca108 --- /dev/null +++ b/hotline/tracker.go @@ -0,0 +1,198 @@ +package hotline + +import ( + "bytes" + "encoding/binary" + "fmt" + "github.com/jhalter/mobius/concat" + "net" + "strconv" + "time" +) + +type TrackerRegistration struct { + Port []byte // Server’s listening UDP port number TODO: wat? + UserCount int // Number of users connected to this particular server + PassID []byte // Random number generated by the server + Name string // Server’s name + Description string // Description of the server +} + +func (tr *TrackerRegistration) Payload() []byte { + userCount := make([]byte, 2) + binary.BigEndian.PutUint16(userCount, uint16(tr.UserCount)) + + return concat.Slices( + []byte{0x00, 0x01}, + tr.Port, + userCount, + []byte{0x00, 0x00}, + tr.PassID, + []byte{uint8(len(tr.Name))}, + []byte(tr.Name), + []byte{uint8(len(tr.Description))}, + []byte(tr.Description), + ) +} + +func register(tracker string, tr TrackerRegistration) error { + conn, err := net.Dial("udp", tracker) + if err != nil { + return err + } + + if _, err := conn.Write(tr.Payload()); err != nil { + return err + } + + return nil +} + +type ServerListing struct { +} + +const trackerTimeout = 5 * time.Second + +// All string values use 8-bit ASCII character set encoding. +// Client Interface with Tracker +// After establishing a connection with tracker, the following information is sent: +// Description Size Data Note +// Magic number 4 ‘HTRK’ +// Version 2 1 or 2 Old protocol (1) or new (2) + +// Reply received from the tracker starts with a header: +// + +type TrackerHeader struct { + Protocol [4]byte // "HTRK" 0x4854524B + Version [2]byte // Old protocol (1) or new (2) +} + +//Message type 2 1 Sending list of servers +//Message data size 2 Remaining size of this request +//Number of servers 2 Number of servers in the server list +//Number of servers 2 Same as previous field +type ServerInfoHeader struct { + MsgType [2]byte // always has value of 1 + MsgDataSize [2]byte // Remaining size of request + SrvCount [2]byte // Number of servers in the server list + SrvCountDup [2]byte // Same as previous field ¯\_(ツ)_/¯ +} + +type ServerRecord struct { + IPAddr []byte + Port []byte + NumUsers []byte // Number of users connected to this particular server + Unused []byte + NameSize byte // Length of name string + Name []byte // Server’s name + DescriptionSize byte + Description []byte +} + +func GetListing(addr string) ([]ServerRecord, error) { + conn, err := net.DialTimeout("tcp", addr, trackerTimeout) + if err != nil { + return nil, err + } + //spew.Dump(conn) + _, err = conn.Write( + []byte{ + 0x48, 0x54, 0x52, 0x4B, // HTRK + 0x00, 0x01, // Version + }, + ) + if err != nil { + return nil, err + } + + totalRead := 0 + + buf := make([]byte, 4096) // handshakes are always 12 bytes in length + var readLen int + if readLen, err = conn.Read(buf); err != nil { + return nil, err + } + totalRead += readLen + + var th TrackerHeader + if err := binary.Read(bytes.NewReader(buf[:6]), binary.BigEndian, &th); err != nil { + return nil, err + } + + var info ServerInfoHeader + if err := binary.Read(bytes.NewReader(buf[6:14]), binary.BigEndian, &info); err != nil { + return nil, err + } + + payloadSize := int(binary.BigEndian.Uint16(info.MsgDataSize[:])) + + if totalRead < payloadSize { + for { + //fmt.Printf("totalRead: %v", totalRead) + //fmt.Printf("readLen: %v Payload size: %v, Server count: %x\n", readLen, payloadSize, info.SrvCount) + + if readLen, err = conn.Read(buf); err != nil { + return nil, err + } + totalRead += readLen + if totalRead >= payloadSize { + break + } + } + } + //fmt.Println("passed read") + totalSrv := int(binary.BigEndian.Uint16(info.SrvCount[:])) + + //fmt.Printf("readLen: %v Payload size: %v, Server count: %x\n", readLen, payloadSize, info.SrvCount) + srvBuf := buf[14:totalRead] + + totalRead += readLen + + var servers []ServerRecord + for { + var srv ServerRecord + n, _ := srv.Read(srvBuf) + servers = append(servers, srv) + + srvBuf = srvBuf[n:] + // fmt.Printf("srvBuf len: %v\n", len(srvBuf)) + if len(servers) == totalSrv { + return servers, nil + } + + if len(srvBuf) == 0 { + if readLen, err = conn.Read(buf); err != nil { + return nil, err + } + srvBuf = buf[8:readLen] + } + } + + return servers, nil +} + +func (s *ServerRecord) Read(b []byte) (n int, err error) { + s.IPAddr = b[0:4] + s.Port = b[4:6] + s.NumUsers = b[6:8] + s.NameSize = b[10] + nameLen := int(b[10]) + s.Name = b[11 : 11+nameLen] + s.DescriptionSize = b[11+nameLen] + s.Description = b[12+nameLen : 12+nameLen+int(s.DescriptionSize)] + + return 12 + nameLen + int(s.DescriptionSize), nil +} + +func (s *ServerRecord) PortInt() int { + data := binary.BigEndian.Uint16(s.Port) + return int(data) +} + +func (s *ServerRecord) Addr() string { + return fmt.Sprintf("%s:%s", + net.IP(s.IPAddr), + strconv.Itoa(int(binary.BigEndian.Uint16(s.Port))), + ) +} diff --git a/hotline/tracker_test.go b/hotline/tracker_test.go new file mode 100644 index 0000000..b904462 --- /dev/null +++ b/hotline/tracker_test.go @@ -0,0 +1,57 @@ +package hotline + +import ( + "reflect" + "testing" +) + +func TestTrackerRegistration_Payload(t *testing.T) { + type fields struct { + Port []byte + UserCount int + PassID []byte + Name string + Description string + } + tests := []struct { + name string + fields fields + want []byte + }{ + { + name: "returns expected payload bytes", + fields: fields{ + Port: []byte{0x00, 0x10}, + UserCount: 2, + PassID: []byte{0x00, 0x00, 0x00, 0x01}, + Name: "Test Serv", + Description: "Fooz", + }, + want: []byte{ + 0x00, 0x01, + 0x00, 0x10, + 0x00, 0x02, + 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, + 0x09, + 0x54, 0x65, 0x73, 0x74, 0x20, 0x53, 0x65, 0x72, 0x76, + 0x04, + 0x46, 0x6f, 0x6f, 0x7a, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tr := &TrackerRegistration{ + Port: tt.fields.Port, + UserCount: tt.fields.UserCount, + PassID: tt.fields.PassID, + Name: tt.fields.Name, + Description: tt.fields.Description, + } + if got := tr.Payload(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("Payload() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/hotline/transaction.go b/hotline/transaction.go new file mode 100644 index 0000000..8fbdf35 --- /dev/null +++ b/hotline/transaction.go @@ -0,0 +1,262 @@ +package hotline + +import ( + "encoding/binary" + "errors" + "fmt" + "github.com/jhalter/mobius/concat" + "math/rand" + "net" +) + +const ( + tranError = 0 + tranGetMsgs = 101 + tranNewMsg = 102 + tranOldPostNews = 103 + tranServerMsg = 104 + tranChatSend = 105 + tranChatMsg = 106 + tranLogin = 107 + tranSendInstantMsg = 108 + tranShowAgreement = 109 + tranDisconnectUser = 110 + // tranDisconnectMsg = 111 TODO: implement friendly disconnect + tranInviteNewChat = 112 + tranInviteToChat = 113 + tranRejectChatInvite = 114 + tranJoinChat = 115 + tranLeaveChat = 116 + tranNotifyChatChangeUser = 117 + tranNotifyChatDeleteUser = 118 + tranNotifyChatSubject = 119 + tranSetChatSubject = 120 + tranAgreed = 121 + tranGetFileNameList = 200 + tranDownloadFile = 202 + tranUploadFile = 203 + tranNewFolder = 205 + tranDeleteFile = 204 + tranGetFileInfo = 206 + tranSetFileInfo = 207 + tranMoveFile = 208 + // tranMakeFileAlias = 209 TODO: implement file alias command + tranDownloadFldr = 210 + // tranDownloadInfo = 211 TODO: implement file transfer queue + // tranDownloadBanner = 212 TODO: figure out what this is used for + tranUploadFldr = 213 + tranGetUserNameList = 300 + tranNotifyChangeUser = 301 + tranNotifyDeleteUser = 302 + tranGetClientInfoText = 303 + tranSetClientUserInfo = 304 + tranListUsers = 348 + // tranUpdateUser = 349 TODO: implement user updates from the > 1.5 account editor + tranNewUser = 350 + tranDeleteUser = 351 + tranGetUser = 352 + tranSetUser = 353 + tranUserAccess = 354 + tranUserBroadcast = 355 + tranGetNewsCatNameList = 370 + tranGetNewsArtNameList = 371 + tranDelNewsItem = 380 + tranNewNewsFldr = 381 + tranNewNewsCat = 382 + tranGetNewsArtData = 400 + tranPostNewsArt = 410 + tranDelNewsArt = 411 + tranKeepAlive = 500 +) + +type Transaction struct { + clientID *[]byte + + Flags byte // Reserved (should be 0) + IsReply byte // Request (0) or reply (1) + Type []byte // Requested operation (user defined) + ID []byte // Unique transaction ID (must be != 0) + ErrorCode []byte // Used in the reply (user defined, 0 = no error) + TotalSize []byte // Total data size for the transaction (all parts) + DataSize []byte // Size of data in this transaction part. This allows splitting large transactions into smaller parts. + ParamCount []byte // Number of the parameters for this transaction + Fields []Field +} + +func NewTransaction(t int, clientID *[]byte, fields ...Field) *Transaction { + typeSlice := make([]byte, 2) + binary.BigEndian.PutUint16(typeSlice, uint16(t)) + + idSlice := make([]byte, 4) + binary.BigEndian.PutUint32(idSlice, rand.Uint32()) + + return &Transaction{ + clientID: clientID, + Flags: 0x00, + IsReply: 0x00, + Type: typeSlice, + ID: idSlice, + ErrorCode: []byte{0, 0, 0, 0}, + Fields: fields, + } +} + +// ReadTransaction parses a byte slice into a struct. The input slice may be shorter or longer +// that the transaction size depending on what was read from the network connection. +func ReadTransaction(buf []byte) (*Transaction, int, error) { + totalSize := binary.BigEndian.Uint32(buf[12:16]) + + // the buf may include extra bytes that are not part of the transaction + // tranLen represents the length of bytes that are part of the transaction + tranLen := int(20 + totalSize) + + if tranLen > len(buf) { + return nil, 0, errors.New("buflen too small for tranLen") + } + fields, err := ReadFields(buf[20:22], buf[22:tranLen]) + if err != nil { + return nil, 0, err + } + + return &Transaction{ + Flags: buf[0], + IsReply: buf[1], + Type: buf[2:4], + ID: buf[4:8], + ErrorCode: buf[8:12], + TotalSize: buf[12:16], + DataSize: buf[16:20], + ParamCount: buf[20:22], + Fields: fields, + }, tranLen, nil +} + +func readN(conn net.Conn, n int) ([]Transaction, error) { + buf := make([]byte, 1400) + i := 0 + for { + readLen, err := conn.Read(buf) + if err != nil { + return nil, err + } + + transactions, _, err := readTransactions(buf[:readLen]) + // spew.Fdump(os.Stderr, transactions) + if err != nil { + return nil, err + } + + i += len(transactions) + + if n == i { + return transactions, nil + } + } +} + +func readTransactions(buf []byte) ([]Transaction, int, error) { + var transactions []Transaction + + bufLen := len(buf) + + var bytesRead = 0 + for bytesRead < bufLen { + t, tReadLen, err := ReadTransaction(buf[bytesRead:]) + if err != nil { + return transactions, bytesRead, err + } + bytesRead += tReadLen + + transactions = append(transactions, *t) + } + + return transactions, bytesRead, nil +} + +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: fieldID, + FieldSize: fieldSize, + Data: buf[4 : 4+fieldSizeInt], + }) + + buf = buf[fieldSizeInt+4:] + } + + if len(buf) != 0 { + return []Field{}, fmt.Errorf("extra field bytes") + } + + return fields, nil +} + +func (t Transaction) Payload() []byte { + payloadSize := t.Size() + + fieldCount := make([]byte, 2) + binary.BigEndian.PutUint16(fieldCount, uint16(len(t.Fields))) + + var fieldPayload []byte + for _, field := range t.Fields { + fieldPayload = append(fieldPayload, field.Payload()...) + } + + return concat.Slices( + []byte{t.Flags, t.IsReply}, + t.Type, + t.ID, + t.ErrorCode, + payloadSize, + payloadSize, // this is the dataSize field, but seeming the same as totalSize + fieldCount, + fieldPayload, + ) +} + +// Size returns the total size of the transaction payload +func (t Transaction) Size() []byte { + bs := make([]byte, 4) + + fieldSize := 0 + for _, field := range t.Fields { + fieldSize += len(field.Data) + 4 + } + + binary.BigEndian.PutUint32(bs, uint32(fieldSize+2)) + + return bs +} + +func (t Transaction) GetField(id int) Field { + for _, field := range t.Fields { + if id == int(binary.BigEndian.Uint16(field.ID)) { + return field + } + } + + return Field{} +} diff --git a/hotline/transaction_handlers.go b/hotline/transaction_handlers.go new file mode 100644 index 0000000..b06297d --- /dev/null +++ b/hotline/transaction_handlers.go @@ -0,0 +1,1577 @@ +package hotline + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "github.com/davecgh/go-spew/spew" + "gopkg.in/yaml.v2" + "io/ioutil" + "math/big" + "os" + "sort" + "strings" + "time" +) + +type TransactionType struct { + Access int // Specifies access privilege required to perform the transaction + DenyMsg string // The error reply message when user does not have access + Handler func(*ClientConn, *Transaction) ([]Transaction, error) // function for handling the transaction type + Name string // Name of transaction as it will appear in logging + RequiredFields []requiredField +} + +var TransactionHandlers = map[uint16]TransactionType{ + // Server initiated + tranChatMsg: { + Name: "tranChatMsg", + }, + // Server initiated + tranNotifyChangeUser: { + Name: "tranNotifyChangeUser", + }, + tranError: { + Name: "tranError", + }, + tranShowAgreement: { + Name: "tranShowAgreement", + }, + tranUserAccess: { + Name: "tranUserAccess", + }, + tranNotifyDeleteUser: { + Name: "tranNotifyDeleteUser", + }, + tranAgreed: { + Access: accessAlwaysAllow, + Name: "tranAgreed", + Handler: HandleTranAgreed, + }, + tranChatSend: { + Access: accessSendChat, + DenyMsg: "You are not allowed to participate in chat.", + Handler: HandleChatSend, + Name: "tranChatSend", + RequiredFields: []requiredField{ + { + ID: fieldData, + minLen: 0, + }, + }, + }, + tranDelNewsArt: { + Access: accessNewsDeleteArt, + DenyMsg: "You are not allowed to delete news articles.", + Name: "tranDelNewsArt", + Handler: HandleDelNewsArt, + }, + tranDelNewsItem: { + Access: accessAlwaysAllow, // Granular access enforced inside the handler + // Has multiple access flags: News Delete Folder (37) or News Delete Category (35) + // TODO: Implement inside the handler + Name: "tranDelNewsItem", + Handler: HandleDelNewsItem, + }, + tranDeleteFile: { + Access: accessAlwaysAllow, // Granular access enforced inside the handler + Name: "tranDeleteFile", + Handler: HandleDeleteFile, + }, + tranDeleteUser: { + Access: accessDeleteUser, + DenyMsg: "You are not allowed to delete accounts.", + Name: "tranDeleteUser", + Handler: HandleDeleteUser, + }, + tranDisconnectUser: { + Access: accessDisconUser, + DenyMsg: "You are not allowed to disconnect users.", + Name: "tranDisconnectUser", + Handler: HandleDisconnectUser, + }, + tranDownloadFile: { + Access: accessDownloadFile, + DenyMsg: "You are not allowed to download files.", + Name: "tranDownloadFile", + Handler: HandleDownloadFile, + }, + tranDownloadFldr: { + Access: accessDownloadFile, // There is no specific access flag for folder vs file download + DenyMsg: "You are not allowed to download files.", + Name: "tranDownloadFldr", + Handler: HandleDownloadFolder, + }, + tranGetClientInfoText: { + Access: accessGetClientInfo, + DenyMsg: "You are not allowed to get client info", + Name: "tranGetClientInfoText", + Handler: HandleGetClientConnInfoText, + }, + tranGetFileInfo: { + Access: accessAlwaysAllow, + Name: "tranGetFileInfo", + Handler: HandleGetFileInfo, + }, + tranGetFileNameList: { + Access: accessAlwaysAllow, + Name: "tranGetFileNameList", + Handler: HandleGetFileNameList, + }, + tranGetMsgs: { + Access: accessNewsReadArt, + DenyMsg: "You are not allowed to read news.", + Name: "tranGetMsgs", + Handler: HandleGetMsgs, + }, + tranGetNewsArtData: { + Access: accessNewsReadArt, + DenyMsg: "You are not allowed to read news.", + Name: "tranGetNewsArtData", + Handler: HandleGetNewsArtData, + }, + tranGetNewsArtNameList: { + Access: accessNewsReadArt, + DenyMsg: "You are not allowed to read news.", + Name: "tranGetNewsArtNameList", + Handler: HandleGetNewsArtNameList, + }, + tranGetNewsCatNameList: { + Access: accessNewsReadArt, + DenyMsg: "You are not allowed to read news.", + Name: "tranGetNewsCatNameList", + Handler: HandleGetNewsCatNameList, + }, + tranGetUser: { + Access: accessOpenUser, + DenyMsg: "You are not allowed to view accounts.", + Name: "tranGetUser", + Handler: HandleGetUser, + }, + tranGetUserNameList: { + Access: accessAlwaysAllow, + Name: "tranHandleGetUserNameList", + Handler: HandleGetUserNameList, + }, + tranInviteNewChat: { + Access: accessOpenChat, + DenyMsg: "You are not allowed to request private chat.", + Name: "tranInviteNewChat", + Handler: HandleInviteNewChat, + }, + tranInviteToChat: { + Access: accessOpenChat, + DenyMsg: "You are not allowed to request private chat.", + Name: "tranInviteToChat", + Handler: HandleInviteToChat, + }, + tranJoinChat: { + Access: accessAlwaysAllow, + Name: "tranJoinChat", + Handler: HandleJoinChat, + }, + tranKeepAlive: { + Access: accessAlwaysAllow, + Name: "tranKeepAlive", + Handler: HandleKeepAlive, + }, + tranLeaveChat: { + Access: accessAlwaysAllow, + Name: "tranJoinChat", + Handler: HandleLeaveChat, + }, + + tranListUsers: { + Access: accessOpenUser, + DenyMsg: "You are not allowed to view accounts.", + Name: "tranListUsers", + Handler: HandleListUsers, + }, + tranMoveFile: { + Access: accessMoveFile, + DenyMsg: "You are not allowed to move files.", + Name: "tranMoveFile", + Handler: HandleMoveFile, + }, + tranNewFolder: { + Access: accessCreateFolder, + DenyMsg: "You are not allow to create folders.", + Name: "tranNewFolder", + Handler: HandleNewFolder, + }, + tranNewNewsCat: { + Access: accessNewsCreateCat, + DenyMsg: "You are not allowed to create news categories.", + Name: "tranNewNewsCat", + Handler: HandleNewNewsCat, + }, + tranNewNewsFldr: { + Access: accessNewsCreateFldr, + DenyMsg: "You are not allowed to create news folders.", + Name: "tranNewNewsFldr", + Handler: HandleNewNewsFldr, + }, + tranNewUser: { + Access: accessCreateUser, + DenyMsg: "You are not allowed to create new accounts.", + Name: "tranNewUser", + Handler: HandleNewUser, + }, + tranOldPostNews: { + Access: accessNewsPostArt, + DenyMsg: "You are not allowed to post news.", + Name: "tranOldPostNews", + Handler: HandleTranOldPostNews, + }, + tranPostNewsArt: { + Access: accessNewsPostArt, + DenyMsg: "You are not allowed to post news articles.", + Name: "tranPostNewsArt", + Handler: HandlePostNewsArt, + }, + tranRejectChatInvite: { + Access: accessAlwaysAllow, + Name: "tranRejectChatInvite", + Handler: HandleRejectChatInvite, + }, + tranSendInstantMsg: { + Access: accessAlwaysAllow, + //Access: accessSendPrivMsg, + //DenyMsg: "You are not allowed to send private messages", + Name: "tranSendInstantMsg", + Handler: HandleSendInstantMsg, + RequiredFields: []requiredField{ + { + ID: fieldData, + minLen: 0, + }, + { + ID: fieldUserID, + }, + }, + }, + tranSetChatSubject: { + Access: accessAlwaysAllow, + Name: "tranSetChatSubject", + Handler: HandleSetChatSubject, + }, + tranSetClientUserInfo: { + Access: accessAlwaysAllow, + Name: "tranSetClientUserInfo", + Handler: HandleSetClientUserInfo, + }, + tranSetFileInfo: { + Access: accessAlwaysAllow, // granular access is in the handler + Name: "tranSetFileInfo", + Handler: HandleSetFileInfo, + }, + tranSetUser: { + Access: accessModifyUser, + DenyMsg: "You are not allowed to modify accounts.", + Name: "tranSetUser", + Handler: HandleSetUser, + }, + tranUploadFile: { + Access: accessUploadFile, + DenyMsg: "You are not allowed to upload files.", + Name: "tranUploadFile", + Handler: HandleUploadFile, + }, + tranUploadFldr: { + Access: accessAlwaysAllow, // TODO: what should this be? + Name: "tranUploadFldr", + Handler: HandleUploadFolder, + }, + tranUserBroadcast: { + Access: accessBroadcast, + DenyMsg: "You are not allowed to send broadcast messages.", + Name: "tranUserBroadcast", + Handler: HandleUserBroadcast, + }, +} + +func HandleChatSend(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + // Truncate long usernames + trunc := fmt.Sprintf("%13s", *cc.UserName) + formattedMsg := fmt.Sprintf("\r%.14s: %s", trunc, t.GetField(fieldData).Data) + + // By holding the option key, Hotline chat allows users to send /me formatted messages like: + // *** Halcyon does stuff + // This is indicated by the presence of the optional field fieldChatOptions in the transaction payload + if t.GetField(fieldChatOptions).Data != nil { + formattedMsg = fmt.Sprintf("*** %s %s\r", *cc.UserName, t.GetField(fieldData).Data) + } + + if bytes.Equal(t.GetField(fieldData).Data, []byte("/stats")) { + formattedMsg = strings.Replace(cc.Server.Stats.String(), "\n", "\r", -1) + } + + chatID := t.GetField(fieldChatID).Data + // a non-nil chatID indicates the message belongs to a private chat + if chatID != nil { + chatInt := binary.BigEndian.Uint32(chatID) + privChat := cc.Server.PrivateChats[chatInt] + + // send the message to all connected clients of the private chat + for _, c := range privChat.ClientConn { + res = append(res, *NewTransaction( + tranChatMsg, + c.ID, + NewField(fieldChatID, chatID), + NewField(fieldData, []byte(formattedMsg)), + )) + } + return res, err + } + + for _, c := range sortedClients(cc.Server.Clients) { + // Filter out clients that do not have the read chat permission + if authorize(c.Account.Access, accessReadChat) { + res = append(res, *NewTransaction(tranChatMsg, c.ID, NewField(fieldData, []byte(formattedMsg)))) + } + } + + return res, err +} + +// HandleSendInstantMsg sends instant message to the user on the current server. +// Fields used in the request: +// 103 User ID +// 113 Options +// One of the following values: +// - User message (myOpt_UserMessage = 1) +// - Refuse message (myOpt_RefuseMessage = 2) +// - Refuse chat (myOpt_RefuseChat = 3) +// - Automatic response (myOpt_AutomaticResponse = 4)" +// 101 Data Optional +// 214 Quoting message Optional +// +//Fields used in the reply: +// None +func HandleSendInstantMsg(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + msg := t.GetField(fieldData) + ID := t.GetField(fieldUserID) + // TODO: Implement reply quoting + //options := transaction.GetField(hotline.fieldOptions) + + res = append(res, + *NewTransaction( + tranServerMsg, + &ID.Data, + NewField(fieldData, msg.Data), + NewField(fieldUserName, *cc.UserName), + NewField(fieldUserID, *cc.ID), + NewField(fieldOptions, []byte{0, 1}), + ), + ) + id, _ := byteToInt(ID.Data) + + //keys := make([]uint16, 0, len(cc.Server.Clients)) + //for k := range cc.Server.Clients { + // keys = append(keys, k) + //} + + otherClient := cc.Server.Clients[uint16(id)] + if otherClient == nil { + return res, errors.New("ohno") + } + + // Respond with auto reply if other client has it enabled + if len(*otherClient.AutoReply) > 0 { + res = append(res, + *NewTransaction( + tranServerMsg, + cc.ID, + NewField(fieldData, *otherClient.AutoReply), + NewField(fieldUserName, *otherClient.UserName), + NewField(fieldUserID, *otherClient.ID), + NewField(fieldOptions, []byte{0, 1}), + ), + ) + } + + res = append(res, cc.NewReply(t)) + + return res, err +} + +func HandleGetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + fileName := string(t.GetField(fieldFileName).Data) + filePath := cc.Server.Config.FileRoot + ReadFilePath(t.GetField(fieldFilePath).Data) + spew.Dump(cc.Server.Config.FileRoot) + + ffo, err := NewFlattenedFileObject(filePath, fileName) + if err != nil { + return res, err + } + + res = append(res, cc.NewReply(t, + NewField(fieldFileName, []byte(fileName)), + NewField(fieldFileTypeString, ffo.FlatFileInformationFork.TypeSignature), + NewField(fieldFileCreatorString, ffo.FlatFileInformationFork.CreatorSignature), + NewField(fieldFileComment, ffo.FlatFileInformationFork.Comment), + NewField(fieldFileType, ffo.FlatFileInformationFork.TypeSignature), + NewField(fieldFileCreateDate, ffo.FlatFileInformationFork.CreateDate), + NewField(fieldFileModifyDate, ffo.FlatFileInformationFork.ModifyDate), + NewField(fieldFileSize, ffo.FlatFileDataForkHeader.DataSize), + )) + return res, err +} + +// HandleSetFileInfo updates a file or folder name and/or comment from the Get Info window +// TODO: Implement support for comments +// Fields used in the request: +// * 201 File name +// * 202 File path Optional +// * 211 File new name Optional +// * 210 File comment Optional +// Fields used in the reply: None +func HandleSetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + fileName := string(t.GetField(fieldFileName).Data) + filePath := cc.Server.Config.FileRoot + ReadFilePath(t.GetField(fieldFilePath).Data) + //fileComment := t.GetField(fieldFileComment).Data + fileNewName := t.GetField(fieldFileNewName).Data + + if fileNewName != nil { + path := filePath + "/" + fileName + fi, err := os.Stat(path) + if err != nil { + return res, err + } + switch mode := fi.Mode(); { + case mode.IsDir(): + if !authorize(cc.Account.Access, accessRenameFolder) { + res = append(res, cc.NewErrReply(t, "You are not allowed to rename folders.")) + return res, err + } + case mode.IsRegular(): + if !authorize(cc.Account.Access, accessRenameFile) { + res = append(res, cc.NewErrReply(t, "You are not allowed to rename files.")) + return res, err + } + } + + err = os.Rename(filePath+"/"+fileName, filePath+"/"+string(fileNewName)) + if os.IsNotExist(err) { + res = append(res, cc.NewErrReply(t, "Cannot rename file "+fileName+" because it does not exist or cannot be found.")) + return res, err + } + } + + res = append(res, cc.NewReply(t)) + return res, err +} + +// HandleDeleteFile deletes a file or folder +// Fields used in the request: +// * 201 File name +// * 202 File path +// Fields used in the reply: none +func HandleDeleteFile(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + fileName := string(t.GetField(fieldFileName).Data) + filePath := cc.Server.Config.FileRoot + ReadFilePath(t.GetField(fieldFilePath).Data) + + path := "./" + filePath + "/" + fileName + + cc.Server.Logger.Debugw("Delete file", "src", filePath+"/"+fileName) + + fi, err := os.Stat(path) + if err != nil { + res = append(res, cc.NewErrReply(t, "Cannot delete file "+fileName+" because it does not exist or cannot be found.")) + return res, nil + } + switch mode := fi.Mode(); { + case mode.IsDir(): + if !authorize(cc.Account.Access, accessDeleteFolder) { + res = append(res, cc.NewErrReply(t, "You are not allowed to delete folders.")) + return res, err + } + case mode.IsRegular(): + if !authorize(cc.Account.Access, accessDeleteFile) { + res = append(res, cc.NewErrReply(t, "You are not allowed to delete files.")) + return res, err + } + } + + if err := os.RemoveAll(path); err != nil { + return res, err + } + + res = append(res, cc.NewReply(t)) + return res, err +} + +// HandleMoveFile moves files or folders. Note: seemingly not documented +func HandleMoveFile(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + fileName := string(t.GetField(fieldFileName).Data) + filePath := "./" + cc.Server.Config.FileRoot + ReadFilePath(t.GetField(fieldFilePath).Data) + fileNewPath := "./" + cc.Server.Config.FileRoot + ReadFilePath(t.GetField(fieldFileNewPath).Data) + + cc.Server.Logger.Debugw("Move file", "src", filePath+"/"+fileName, "dst", fileNewPath+"/"+fileName) + + path := filePath + "/" + fileName + fi, err := os.Stat(path) + if err != nil { + return res, err + } + switch mode := fi.Mode(); { + case mode.IsDir(): + if !authorize(cc.Account.Access, accessMoveFolder) { + res = append(res, cc.NewErrReply(t, "You are not allowed to move folders.")) + return res, err + } + case mode.IsRegular(): + if !authorize(cc.Account.Access, accessMoveFile) { + res = append(res, cc.NewErrReply(t, "You are not allowed to move files.")) + return res, err + } + } + + err = os.Rename(filePath+"/"+fileName, fileNewPath+"/"+fileName) + if os.IsNotExist(err) { + res = append(res, cc.NewErrReply(t, "Cannot delete file "+fileName+" because it does not exist or cannot be found.")) + return res, err + } + if err != nil { + return []Transaction{}, err + } + // TODO: handle other possible errors; e.g. file delete fails due to file permission issue + + res = append(res, cc.NewReply(t)) + return res, err +} + +func HandleNewFolder(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + newFolderPath := cc.Server.Config.FileRoot + + // fieldFilePath is only present for nested paths + if t.GetField(fieldFilePath).Data != nil { + newFp := NewFilePath(t.GetField(fieldFilePath).Data) + newFolderPath += newFp.String() + } + newFolderPath += "/" + string(t.GetField(fieldFileName).Data) + + if err := os.Mkdir(newFolderPath, 0777); err != nil { + // TODO: Send error response to client + return []Transaction{}, err + } + + res = append(res, cc.NewReply(t)) + return res, err +} + +func HandleSetUser(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + login := DecodeUserString(t.GetField(fieldUserLogin).Data) + userName := string(t.GetField(fieldUserName).Data) + + newAccessLvl := t.GetField(fieldUserAccess).Data + + account := cc.Server.Accounts[login] + account.Access = &newAccessLvl + account.Name = userName + + // If the password field is cleared in the Hotline edit user UI, the SetUser transaction does + // not include fieldUserPassword + if t.GetField(fieldUserPassword).Data == nil { + account.Password = hashAndSalt([]byte("")) + } + if len(t.GetField(fieldUserPassword).Data) > 1 { + account.Password = hashAndSalt(t.GetField(fieldUserPassword).Data) + } + + file := cc.Server.ConfigDir + "Users/" + login + ".yaml" + out, err := yaml.Marshal(&account) + if err != nil { + return res, err + } + if err := ioutil.WriteFile(file, out, 0666); err != nil { + return res, err + } + + // Notify connected clients logged in as the user of the new access level + for _, c := range cc.Server.Clients { + if c.Account.Login == login { + // Note: comment out these two lines to test server-side deny messages + newT := NewTransaction(tranUserAccess, c.ID, NewField(fieldUserAccess, newAccessLvl)) + res = append(res, *newT) + + flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(*c.Flags))) + if authorize(c.Account.Access, accessDisconUser) { + flagBitmap.SetBit(flagBitmap, userFlagAdmin, 1) + } else { + flagBitmap.SetBit(flagBitmap, userFlagAdmin, 0) + } + binary.BigEndian.PutUint16(*c.Flags, uint16(flagBitmap.Int64())) + + c.Account.Access = account.Access + + cc.sendAll( + tranNotifyChangeUser, + NewField(fieldUserID, *c.ID), + NewField(fieldUserFlags, *c.Flags), + NewField(fieldUserName, *c.UserName), + NewField(fieldUserIconID, *c.Icon), + ) + } + } + + // TODO: If we have just promoted a connected user to admin, notify + // connected clients to turn the user red + + res = append(res, cc.NewReply(t)) + return res, err +} + +func HandleGetUser(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + userLogin := string(t.GetField(fieldUserLogin).Data) + decodedUserLogin := NegatedUserString(t.GetField(fieldUserLogin).Data) + account := cc.Server.Accounts[userLogin] + if account == nil { + errorT := cc.NewErrReply(t, "Account does not exist.") + res = append(res, errorT) + return res, err + } + + res = append(res, cc.NewReply(t, + NewField(fieldUserName, []byte(account.Name)), + NewField(fieldUserLogin, []byte(decodedUserLogin)), + NewField(fieldUserPassword, []byte(account.Password)), + NewField(fieldUserAccess, *account.Access), + )) + return res, err +} + +func HandleListUsers(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + var userFields []Field + // TODO: make order deterministic + for _, acc := range cc.Server.Accounts { + userField := acc.Payload() + userFields = append(userFields, NewField(fieldData, userField)) + } + + res = append(res, cc.NewReply(t, userFields...)) + return res, err +} + +// HandleNewUser creates a new user account +func HandleNewUser(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + login := DecodeUserString(t.GetField(fieldUserLogin).Data) + + // If the account already exists, reply with an error + // TODO: make order deterministic + if _, ok := cc.Server.Accounts[login]; ok { + res = append(res, cc.NewErrReply(t, "Cannot create account "+login+" because there is already an account with that login.")) + return res, err + } + + if err := cc.Server.NewUser( + login, + string(t.GetField(fieldUserName).Data), + string(t.GetField(fieldUserPassword).Data), + t.GetField(fieldUserAccess).Data, + ); err != nil { + return []Transaction{}, err + } + + res = append(res, cc.NewReply(t)) + return res, err +} + +func HandleDeleteUser(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + // TODO: Handle case where account doesn't exist; e.g. delete race condition + login := DecodeUserString(t.GetField(fieldUserLogin).Data) + + if err := cc.Server.DeleteUser(login); err != nil { + return res, err + } + + res = append(res, cc.NewReply(t)) + return res, err +} + +// HandleUserBroadcast sends an Administrator Message to all connected clients of the server +func HandleUserBroadcast(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + cc.sendAll( + tranServerMsg, + NewField(fieldData, t.GetField(tranGetMsgs).Data), + NewField(fieldChatOptions, []byte{0}), + ) + + res = append(res, cc.NewReply(t)) + return res, err +} + +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") +} + +func HandleGetClientConnInfoText(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + clientID, _ := byteToInt(t.GetField(fieldUserID).Data) + + clientConn := cc.Server.Clients[uint16(clientID)] + if clientConn == nil { + return res, errors.New("invalid client") + } + + // TODO: Implement non-hardcoded values + template := `Nickname: %s +Name: %s +Account: %s +Address: %s + +-------- File Downloads --------- + +%s + +------- Folder Downloads -------- + +None. + +--------- File Uploads ---------- + +None. + +-------- Folder Uploads --------- + +None. + +------- Waiting Downloads ------- + +None. + + ` + + activeDownloads := clientConn.Transfers[FileDownload] + activeDownloadList := "None." + for _, dl := range activeDownloads { + activeDownloadList += dl.String() + "\n" + } + + template = fmt.Sprintf( + template, + *clientConn.UserName, + clientConn.Account.Name, + clientConn.Account.Login, + clientConn.Connection.RemoteAddr().String(), + activeDownloadList, + ) + template = strings.Replace(template, "\n", "\r", -1) + + res = append(res, cc.NewReply(t, + NewField(fieldData, []byte(template)), + NewField(fieldUserName, *clientConn.UserName), + )) + return res, err +} + +func HandleGetUserNameList(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + res = append(res, cc.NewReply(t, cc.Server.connectedUsers()...)) + + return res, err +} + +func (cc *ClientConn) notifyNewUserHasJoined() (res []Transaction, err error) { + // Notify other ccs that a new user has connected + cc.NotifyOthers( + *NewTransaction( + tranNotifyChangeUser, nil, + NewField(fieldUserName, *cc.UserName), + NewField(fieldUserID, *cc.ID), + NewField(fieldUserIconID, *cc.Icon), + NewField(fieldUserFlags, *cc.Flags), + ), + ) + + return res, nil +} + +func HandleTranAgreed(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + bs := make([]byte, 2) + binary.BigEndian.PutUint16(bs, *cc.Server.NextGuestID) + + *cc.UserName = t.GetField(fieldUserName).Data + *cc.ID = bs + *cc.Icon = t.GetField(fieldUserIconID).Data + + options := t.GetField(fieldOptions).Data + optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options))) + + flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(*cc.Flags))) + + // Check refuse private PM option + if optBitmap.Bit(refusePM) == 1 { + flagBitmap.SetBit(flagBitmap, userFlagRefusePM, 1) + binary.BigEndian.PutUint16(*cc.Flags, uint16(flagBitmap.Int64())) + } + + // Check refuse private chat option + if optBitmap.Bit(refuseChat) == 1 { + flagBitmap.SetBit(flagBitmap, userFLagRefusePChat, 1) + binary.BigEndian.PutUint16(*cc.Flags, uint16(flagBitmap.Int64())) + } + + // Check auto response + if optBitmap.Bit(autoResponse) == 1 { + *cc.AutoReply = t.GetField(fieldAutomaticResponse).Data + } else { + *cc.AutoReply = []byte{} + } + + _, _ = cc.notifyNewUserHasJoined() + + res = append(res, cc.NewReply(t)) + + return res, err +} + +const defaultNewsDateFormat = "Jan02 15:04" // Jun23 20:49 +// "Mon, 02 Jan 2006 15:04:05 MST" + +const defaultNewsTemplate = `From %s (%s): + +%s + +__________________________________________________________` + +// HandleTranOldPostNews updates the flat news +// Fields used in this request: +// 101 Data +func HandleTranOldPostNews(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + cc.Server.flatNewsMux.Lock() + defer cc.Server.flatNewsMux.Unlock() + + newsDateTemplate := defaultNewsDateFormat + if cc.Server.Config.NewsDateFormat != "" { + newsDateTemplate = cc.Server.Config.NewsDateFormat + } + + newsTemplate := defaultNewsTemplate + if cc.Server.Config.NewsDelimiter != "" { + newsTemplate = cc.Server.Config.NewsDelimiter + } + + newsPost := fmt.Sprintf(newsTemplate+"\r", *cc.UserName, time.Now().Format(newsDateTemplate), t.GetField(fieldData).Data) + newsPost = strings.Replace(newsPost, "\n", "\r", -1) + + // update news in memory + cc.Server.FlatNews = append([]byte(newsPost), cc.Server.FlatNews...) + + // update news on disk + if err := ioutil.WriteFile(cc.Server.ConfigDir+"MessageBoard.txt", cc.Server.FlatNews, 0644); err != nil { + return res, err + } + + // Notify all clients of updated news + cc.sendAll( + tranNewMsg, + NewField(fieldData, []byte(newsPost)), + ) + + res = append(res, cc.NewReply(t)) + return res, err +} + +func HandleDisconnectUser(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + clientConn := cc.Server.Clients[binary.BigEndian.Uint16(t.GetField(fieldUserID).Data)] + + if authorize(clientConn.Account.Access, accessCannotBeDiscon) { + res = append(res, cc.NewErrReply(t, clientConn.Account.Login+" is not allowed to be disconnected.")) + return res, err + } + + if err := clientConn.Connection.Close(); err != nil { + return res, err + } + + res = append(res, cc.NewReply(t)) + return res, err +} + +func HandleGetNewsCatNameList(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + // Fields used in the request: + // 325 News path (Optional) + + newsPath := t.GetField(fieldNewsPath).Data + cc.Server.Logger.Infow("NewsPath: ", "np", string(newsPath)) + + pathStrs := ReadNewsPath(t.GetField(fieldNewsPath).Data) + cats := cc.Server.GetNewsCatByPath(pathStrs) + + // 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] + fieldData = append(fieldData, NewField( + fieldNewsCatListData15, + cat.Payload(), + )) + } + + res = append(res, cc.NewReply(t, fieldData...)) + return res, err +} + +func HandleNewNewsCat(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + name := string(t.GetField(fieldNewsCatName).Data) + pathStrs := ReadNewsPath(t.GetField(fieldNewsPath).Data) + + cats := cc.Server.GetNewsCatByPath(pathStrs) + cats[name] = NewsCategoryListData15{ + Name: name, + Type: []byte{0, 3}, + Articles: map[uint32]*NewsArtData{}, + SubCats: make(map[string]NewsCategoryListData15), + } + + if err := cc.Server.writeThreadedNews(); err != nil { + return res, err + } + res = append(res, cc.NewReply(t)) + return res, err +} + +func HandleNewNewsFldr(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + // Fields used in the request: + // 322 News category name + // 325 News path + name := string(t.GetField(fieldFileName).Data) + pathStrs := ReadNewsPath(t.GetField(fieldNewsPath).Data) + + cc.Server.Logger.Infof("Creating new news folder %s", name) + + cats := cc.Server.GetNewsCatByPath(pathStrs) + cats[name] = NewsCategoryListData15{ + Name: name, + Type: []byte{0, 2}, + Articles: map[uint32]*NewsArtData{}, + SubCats: make(map[string]NewsCategoryListData15), + } + if err := cc.Server.writeThreadedNews(); err != nil { + return res, err + } + res = append(res, cc.NewReply(t)) + return res, err +} + +// Fields used in the request: +// 325 News path Optional +// +// Reply fields: +// 321 News article list data Optional +func HandleGetNewsArtNameList(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + pathStrs := ReadNewsPath(t.GetField(fieldNewsPath).Data) + + var cat NewsCategoryListData15 + cats := cc.Server.ThreadedNews.Categories + + for _, path := range pathStrs { + cat = cats[path] + cats = cats[path].SubCats + } + + nald := cat.GetNewsArtListData() + + res = append(res, cc.NewReply(t, NewField(fieldNewsArtListData, nald.Payload()))) + return res, err +} + +func HandleGetNewsArtData(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + // Request fields + // 325 News path + // 326 News article ID + // 327 News article data flavor + + pathStrs := ReadNewsPath(t.GetField(fieldNewsPath).Data) + + var cat NewsCategoryListData15 + cats := cc.Server.ThreadedNews.Categories + + for _, path := range pathStrs { + cat = cats[path] + cats = cats[path].SubCats + } + newsArtID := t.GetField(fieldNewsArtID).Data + + convertedArtID := binary.BigEndian.Uint16(newsArtID) + + art := cat.Articles[uint32(convertedArtID)] + if art == nil { + res = append(res, cc.NewReply(t)) + return res, err + } + + // Reply fields + // 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 + // 327 News article data flavor "Should be “text/plain” + // 333 News article data Optional (if data flavor is “text/plain”) + + res = append(res, cc.NewReply(t, + NewField(fieldNewsArtTitle, []byte(art.Title)), + NewField(fieldNewsArtPoster, []byte(art.Poster)), + NewField(fieldNewsArtDate, art.Date), + NewField(fieldNewsArtPrevArt, art.PrevArt), + NewField(fieldNewsArtNextArt, art.NextArt), + NewField(fieldNewsArtParentArt, art.ParentArt), + NewField(fieldNewsArt1stChildArt, art.FirstChildArt), + NewField(fieldNewsArtDataFlav, []byte("text/plain")), + NewField(fieldNewsArtData, []byte(art.Data)), + )) + return res, err +} + +func HandleDelNewsItem(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + // Access: News Delete Folder (37) or News Delete Category (35) + + pathStrs := ReadNewsPath(t.GetField(fieldNewsPath).Data) + + // TODO: determine if path is a Folder (Bundle) or Category and check for permission + + cc.Server.Logger.Infof("DelNewsItem %v", pathStrs) + + cats := cc.Server.ThreadedNews.Categories + + delName := pathStrs[len(pathStrs)-1] + if len(pathStrs) > 1 { + for _, path := range pathStrs[0 : len(pathStrs)-1] { + cats = cats[path].SubCats + } + } + + delete(cats, delName) + + err = cc.Server.writeThreadedNews() + if err != nil { + return res, err + } + + // Reply params: none + res = append(res, cc.NewReply(t)) + + return res, err +} + +func HandleDelNewsArt(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + // 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 := binary.BigEndian.Uint16(t.GetField(fieldNewsArtID).Data) + + // TODO: Delete recursive + cats := cc.Server.GetNewsCatByPath(pathStrs[:len(pathStrs)-1]) + + catName := pathStrs[len(pathStrs)-1] + cat := cats[catName] + + delete(cat.Articles, uint32(ID)) + + cats[catName] = cat + if err := cc.Server.writeThreadedNews(); err != nil { + return res, err + } + + res = append(res, cc.NewReply(t)) + return res, err +} + +func HandlePostNewsArt(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + // Request fields + // 325 News path + // 326 News article ID ID of the parent article? + // 328 News article title + // 334 News article flags + // 327 News article data flavor Currently “text/plain” + // 333 News article data + + pathStrs := ReadNewsPath(t.GetField(fieldNewsPath).Data) + cats := cc.Server.GetNewsCatByPath(pathStrs[:len(pathStrs)-1]) + + catName := pathStrs[len(pathStrs)-1] + cat := cats[catName] + + newArt := NewsArtData{ + Title: string(t.GetField(fieldNewsArtTitle).Data), + Poster: string(*cc.UserName), + Date: NewsDate(), + PrevArt: []byte{0, 0, 0, 0}, + NextArt: []byte{0, 0, 0, 0}, + ParentArt: append([]byte{0, 0}, t.GetField(fieldNewsArtID).Data...), + FirstChildArt: []byte{0, 0, 0, 0}, + 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 := binary.BigEndian.Uint16(t.GetField(fieldNewsArtID).Data) + if parentID != 0 { + parentArt := cat.Articles[uint32(parentID)] + + if bytes.Equal(parentArt.FirstChildArt, []byte{0, 0, 0, 0}) { + binary.BigEndian.PutUint32(parentArt.FirstChildArt, nextID) + } + } + + cat.Articles[nextID] = &newArt + + cats[catName] = cat + if err := cc.Server.writeThreadedNews(); err != nil { + return res, err + } + + res = append(res, cc.NewReply(t)) + return res, err +} + +// HandleGetMsgs returns the flat news data +func HandleGetMsgs(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + res = append(res, cc.NewReply(t, NewField(fieldData, cc.Server.FlatNews))) + + return res, err +} + +func HandleDownloadFile(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + fileName := t.GetField(fieldFileName).Data + filePath := ReadFilePath(t.GetField(fieldFilePath).Data) + + ffo, err := NewFlattenedFileObject(cc.Server.Config.FileRoot+filePath, string(fileName)) + if err != nil { + return res, err + } + + transactionRef := cc.Server.NewTransactionRef() + data := binary.BigEndian.Uint32(transactionRef) + + cc.Server.Logger.Infow("File download", "path", filePath) + + ft := &FileTransfer{ + FileName: fileName, + FilePath: []byte(filePath), + ReferenceNumber: transactionRef, + Type: FileDownload, + } + + cc.Server.FileTransfers[data] = ft + cc.Transfers[FileDownload] = append(cc.Transfers[FileDownload], ft) + + res = append(res, cc.NewReply(t, + NewField(fieldRefNum, transactionRef), + NewField(fieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count + NewField(fieldTransferSize, ffo.TransferSize()), + NewField(fieldFileSize, ffo.FlatFileDataForkHeader.DataSize), + )) + + return res, err +} + +// Download all files from the specified folder and sub-folders +// response example +// +// 00 +// 01 +// 00 00 +// 00 00 00 11 +// 00 00 00 00 +// 00 00 00 18 +// 00 00 00 18 +// +// 00 03 +// +// 00 6c // transfer size +// 00 04 // len +// 00 0f d5 ae +// +// 00 dc // field Folder item count +// 00 02 // len +// 00 02 +// +// 00 6b // ref number +// 00 04 // len +// 00 03 64 b1 +func HandleDownloadFolder(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + transactionRef := cc.Server.NewTransactionRef() + data := binary.BigEndian.Uint32(transactionRef) + + fileTransfer := &FileTransfer{ + FileName: t.GetField(fieldFileName).Data, + FilePath: t.GetField(fieldFilePath).Data, + ReferenceNumber: transactionRef, + Type: FolderDownload, + } + cc.Server.FileTransfers[data] = fileTransfer + cc.Transfers[FolderDownload] = append(cc.Transfers[FolderDownload], fileTransfer) + + fp := NewFilePath(t.GetField(fieldFilePath).Data) + + fullFilePath := fmt.Sprintf("./%v/%v", cc.Server.Config.FileRoot+fp.String(), string(fileTransfer.FileName)) + transferSize, err := CalcTotalSize(fullFilePath) + if err != nil { + return res, err + } + itemCount, err := CalcItemCount(fullFilePath) + if err != nil { + return res, err + } + res = append(res, cc.NewReply(t, + NewField(fieldRefNum, transactionRef), + NewField(fieldTransferSize, transferSize), + NewField(fieldFolderItemCount, itemCount), + NewField(fieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count + )) + return res, err +} + +// Upload all files from the local folder and its subfolders to the specified path on the server +// Fields used in the request +// 201 File name +// 202 File path +// 108 Transfer size Total size of all items in the folder +// 220 Folder item count +// 204 File transfer options "Optional Currently set to 1" (TODO: ??) +func HandleUploadFolder(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + transactionRef := cc.Server.NewTransactionRef() + data := binary.BigEndian.Uint32(transactionRef) + + fileTransfer := &FileTransfer{ + FileName: t.GetField(fieldFileName).Data, + FilePath: t.GetField(fieldFilePath).Data, + ReferenceNumber: transactionRef, + Type: FolderUpload, + FolderItemCount: t.GetField(fieldFolderItemCount).Data, + TransferSize: t.GetField(fieldTransferSize).Data, + } + cc.Server.FileTransfers[data] = fileTransfer + + res = append(res, cc.NewReply(t, NewField(fieldRefNum, transactionRef))) + return res, err +} + +func HandleUploadFile(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + fileName := t.GetField(fieldFileName).Data + filePath := t.GetField(fieldFilePath).Data + + transactionRef := cc.Server.NewTransactionRef() + data := binary.BigEndian.Uint32(transactionRef) + + fileTransfer := &FileTransfer{ + FileName: fileName, + FilePath: filePath, + ReferenceNumber: transactionRef, + Type: FileUpload, + } + + cc.Server.FileTransfers[data] = fileTransfer + + res = append(res, cc.NewReply(t, NewField(fieldRefNum, transactionRef))) + return res, err +} + +// User options +const ( + refusePM = 0 + refuseChat = 1 + autoResponse = 2 +) + +func HandleSetClientUserInfo(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + var icon []byte + if len(t.GetField(fieldUserIconID).Data) == 4 { + icon = t.GetField(fieldUserIconID).Data[2:] + } else { + icon = t.GetField(fieldUserIconID).Data + } + *cc.Icon = icon + *cc.UserName = t.GetField(fieldUserName).Data + + // the options field is only passed by the client versions > 1.2.3. + options := t.GetField(fieldOptions).Data + + if options != nil { + optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options))) + flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(*cc.Flags))) + + // Check refuse private PM option + if optBitmap.Bit(refusePM) == 1 { + flagBitmap.SetBit(flagBitmap, userFlagRefusePM, 1) + binary.BigEndian.PutUint16(*cc.Flags, uint16(flagBitmap.Int64())) + } + + // Check refuse private chat option + if optBitmap.Bit(refuseChat) == 1 { + flagBitmap.SetBit(flagBitmap, userFLagRefusePChat, 1) + binary.BigEndian.PutUint16(*cc.Flags, uint16(flagBitmap.Int64())) + } + + // Check auto response + if optBitmap.Bit(autoResponse) == 1 { + *cc.AutoReply = t.GetField(fieldAutomaticResponse).Data + } else { + *cc.AutoReply = []byte{} + } + } + + // Notify all clients of updated user info + cc.sendAll( + tranNotifyChangeUser, + NewField(fieldUserID, *cc.ID), + NewField(fieldUserIconID, *cc.Icon), + NewField(fieldUserFlags, *cc.Flags), + NewField(fieldUserName, *cc.UserName), + ) + + return res, err +} + +// HandleKeepAlive response to keepalive transactions with an empty reply +// HL 1.9.2 Client sends keepalive msg every 3 minutes +// HL 1.2.3 Client doesn't send keepalives +func HandleKeepAlive(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + res = append(res, cc.NewReply(t)) + + return res, err +} + +func HandleGetFileNameList(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + filePath := cc.Server.Config.FileRoot + + path := t.GetField(fieldFilePath).Data + if len(path) > 0 { + filePath = cc.Server.Config.FileRoot + ReadFilePath(path) + } + + fileNames, err := getFileNameList(filePath) + if err != nil { + return res, err + } + + res = append(res, cc.NewReply(t, fileNames...)) + + return res, err +} + +// ================================= +// Hotline private chat flow +// ================================= +// 1. ClientA sends tranInviteNewChat to server with user ID to invite +// 2. Server creates new ChatID +// 3. Server sends tranInviteToChat to invitee +// 4. Server replies to ClientA with new Chat ID +// +// A dialog box pops up in the invitee client with options to accept or decline the invitation. +// If Accepted is clicked: +// 1. ClientB sends tranJoinChat with fieldChatID + +// HandleInviteNewChat invites users to new private chat +func HandleInviteNewChat(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + // Client to Invite + targetID := t.GetField(fieldUserID).Data + newChatID := cc.Server.NewPrivateChat(cc) + + res = append(res, + *NewTransaction( + tranInviteToChat, + &targetID, + NewField(fieldChatID, newChatID), + NewField(fieldUserName, *cc.UserName), + NewField(fieldUserID, *cc.ID), + ), + ) + + res = append(res, + cc.NewReply(t, + NewField(fieldChatID, newChatID), + NewField(fieldUserName, *cc.UserName), + NewField(fieldUserID, *cc.ID), + NewField(fieldUserIconID, *cc.Icon), + NewField(fieldUserFlags, *cc.Flags), + ), + ) + + return res, err +} + +func HandleInviteToChat(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + // Client to Invite + targetID := t.GetField(fieldUserID).Data + chatID := t.GetField(fieldChatID).Data + + res = append(res, + *NewTransaction( + tranInviteToChat, + &targetID, + NewField(fieldChatID, chatID), + NewField(fieldUserName, *cc.UserName), + NewField(fieldUserID, *cc.ID), + ), + ) + res = append(res, + cc.NewReply( + t, + NewField(fieldChatID, chatID), + NewField(fieldUserName, *cc.UserName), + NewField(fieldUserID, *cc.ID), + NewField(fieldUserIconID, *cc.Icon), + NewField(fieldUserFlags, *cc.Flags), + ), + ) + + return res, err +} + +func HandleRejectChatInvite(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + chatID := t.GetField(fieldChatID).Data + chatInt := binary.BigEndian.Uint32(chatID) + + privChat := cc.Server.PrivateChats[chatInt] + + resMsg := append(*cc.UserName, []byte(" declined invitation to chat")...) + + for _, c := range sortedClients(privChat.ClientConn) { + res = append(res, + *NewTransaction( + tranChatMsg, + c.ID, + NewField(fieldChatID, chatID), + NewField(fieldData, resMsg), + ), + ) + } + + return res, err +} + +// HandleJoinChat is sent from a v1.8+ Hotline client when the joins a private chat +// Fields used in the reply: +// * 115 Chat subject +// * 300 User name with info (Optional) +// * 300 (more user names with info) +func HandleJoinChat(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + chatID := t.GetField(fieldChatID).Data + chatInt := binary.BigEndian.Uint32(chatID) + + privChat := cc.Server.PrivateChats[chatInt] + + // Send tranNotifyChatChangeUser to current members of the chat to inform of new user + for _, c := range sortedClients(privChat.ClientConn) { + res = append(res, + *NewTransaction( + tranNotifyChatChangeUser, + c.ID, + NewField(fieldChatID, chatID), + NewField(fieldUserName, *cc.UserName), + NewField(fieldUserID, *cc.ID), + NewField(fieldUserIconID, *cc.Icon), + NewField(fieldUserFlags, *cc.Flags), + ), + ) + } + + privChat.ClientConn[cc.uint16ID()] = cc + + replyFields := []Field{NewField(fieldChatSubject, []byte(privChat.Subject))} + for _, c := range sortedClients(privChat.ClientConn) { + user := User{ + ID: *c.ID, + Icon: *c.Icon, + Flags: *c.Flags, + Name: string(*c.UserName), + } + + replyFields = append(replyFields, NewField(fieldUsernameWithInfo, user.Payload())) + } + + res = append(res, cc.NewReply(t, replyFields...)) + return res, err +} + +// HandleLeaveChat is sent from a v1.8+ Hotline client when the user exits a private chat +// Fields used in the request: +// * 114 fieldChatID +// Reply is not expected. +func HandleLeaveChat(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + chatID := t.GetField(fieldChatID).Data + chatInt := binary.BigEndian.Uint32(chatID) + + privChat := cc.Server.PrivateChats[chatInt] + + delete(privChat.ClientConn, cc.uint16ID()) + + // Notify members of the private chat that the user has left + for _, c := range sortedClients(privChat.ClientConn) { + res = append(res, + *NewTransaction( + tranNotifyChatDeleteUser, + c.ID, + NewField(fieldChatID, chatID), + NewField(fieldUserID, *cc.ID), + ), + ) + } + + return res, err +} + +// 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 +// * 115 Chat subject Chat subject string +// Reply is not expected. +func HandleSetChatSubject(cc *ClientConn, t *Transaction) (res []Transaction, err error) { + chatID := t.GetField(fieldChatID).Data + chatInt := binary.BigEndian.Uint32(chatID) + + privChat := cc.Server.PrivateChats[chatInt] + privChat.Subject = string(t.GetField(fieldChatSubject).Data) + + for _, c := range sortedClients(privChat.ClientConn) { + res = append(res, + *NewTransaction( + tranNotifyChatSubject, + c.ID, + NewField(fieldChatID, chatID), + NewField(fieldChatSubject, t.GetField(fieldChatSubject).Data), + ), + ) + } + + return res, err +} diff --git a/hotline/transaction_handlers_test.go b/hotline/transaction_handlers_test.go new file mode 100644 index 0000000..3042da2 --- /dev/null +++ b/hotline/transaction_handlers_test.go @@ -0,0 +1,394 @@ +package hotline + +import ( + "github.com/stretchr/testify/assert" + "math/rand" + "reflect" + "testing" +) + +func TestHandleSetChatSubject(t *testing.T) { + type args struct { + cc *ClientConn + t *Transaction + } + tests := []struct { + name string + args args + want []Transaction + wantErr bool + }{ + { + name: "sends chat subject to private chat members", + args: args{ + cc: &ClientConn{ + UserName: &[]byte{0x00, 0x01}, + Server: &Server{ + PrivateChats: map[uint32]*PrivateChat{ + uint32(1): { + Subject: "unset", + ClientConn: map[uint16]*ClientConn{ + uint16(1): { + Account: &Account{ + Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: &[]byte{0, 1}, + }, + uint16(2): { + Account: &Account{ + Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: &[]byte{0, 2}, + }, + }, + }, + }, + Clients: map[uint16]*ClientConn{ + uint16(1): { + Account: &Account{ + Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: &[]byte{0, 1}, + }, + uint16(2): { + Account: &Account{ + Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: &[]byte{0, 2}, + }, + }, + }, + }, + t: &Transaction{ + Flags: 0x00, + IsReply: 0x00, + Type: []byte{0, 0x6a}, + ID: []byte{0, 0, 0, 1}, + ErrorCode: []byte{0, 0, 0, 0}, + Fields: []Field{ + NewField(fieldChatID, []byte{0, 0, 0, 1}), + NewField(fieldChatSubject, []byte("Test Subject")), + }, + }, + }, + want: []Transaction{ + { + clientID: &[]byte{0, 1}, + Flags: 0x00, + IsReply: 0x00, + Type: []byte{0, 0x77}, + ID: []byte{0x9a, 0xcb, 0x04, 0x42}, // Random ID from rand.Seed(1) + ErrorCode: []byte{0, 0, 0, 0}, + Fields: []Field{ + NewField(fieldChatID, []byte{0, 0, 0, 1}), + NewField(fieldChatSubject, []byte("Test Subject")), + }, + }, + { + clientID: &[]byte{0, 2}, + Flags: 0x00, + IsReply: 0x00, + Type: []byte{0, 0x77}, + ID: []byte{0xf0, 0xc5, 0x34, 0x1e}, // Random ID from rand.Seed(1) + ErrorCode: []byte{0, 0, 0, 0}, + Fields: []Field{ + NewField(fieldChatID, []byte{0, 0, 0, 1}), + NewField(fieldChatSubject, []byte("Test Subject")), + }, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + rand.Seed(1) // reset seed between tests to make transaction IDs predictable + + t.Run(tt.name, func(t *testing.T) { + got, err := HandleSetChatSubject(tt.args.cc, tt.args.t) + if (err != nil) != tt.wantErr { + t.Errorf("HandleSetChatSubject() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !assert.Equal(t, tt.want, got) { + t.Errorf("HandleSetChatSubject() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHandleLeaveChat(t *testing.T) { + type args struct { + cc *ClientConn + t *Transaction + } + tests := []struct { + name string + args args + want []Transaction + wantErr bool + }{ + { + name: "returns expected transactions", + args: args{ + cc: &ClientConn{ + ID: &[]byte{0, 2}, + Server: &Server{ + PrivateChats: map[uint32]*PrivateChat{ + uint32(1): { + ClientConn: map[uint16]*ClientConn{ + uint16(1): { + Account: &Account{ + Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: &[]byte{0, 1}, + }, + uint16(2): { + Account: &Account{ + Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: &[]byte{0, 2}, + }, + }, + }, + }, + Clients: map[uint16]*ClientConn{ + uint16(1): { + Account: &Account{ + Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: &[]byte{0, 1}, + }, + uint16(2): { + Account: &Account{ + Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: &[]byte{0, 2}, + }, + }, + }, + }, + t: NewTransaction(tranDeleteUser,nil, NewField(fieldChatID, []byte{0, 0, 0, 1})), + }, + want: []Transaction{ + { + clientID: &[]byte{0, 1}, + Flags: 0x00, + IsReply: 0x00, + Type: []byte{0, 0x76}, + ID: []byte{0x9a, 0xcb, 0x04, 0x42}, // Random ID from rand.Seed(1) + ErrorCode: []byte{0, 0, 0, 0}, + Fields: []Field{ + NewField(fieldChatID, []byte{0, 0, 0, 1}), + NewField(fieldUserID, []byte{0, 2}), + }, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + rand.Seed(1) + t.Run(tt.name, func(t *testing.T) { + got, err := HandleLeaveChat(tt.args.cc, tt.args.t) + if (err != nil) != tt.wantErr { + t.Errorf("HandleLeaveChat() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !assert.Equal(t, tt.want, got) { + t.Errorf("HandleLeaveChat() got = %v, want %v", got, tt.want) + } + }) + } +} + + +func TestHandleGetUserNameList(t *testing.T) { + type args struct { + cc *ClientConn + t *Transaction + } + tests := []struct { + name string + args args + want []Transaction + wantErr bool + }{ + { + name: "replies with userlist transaction", + args: args{ + cc: &ClientConn{ + + ID: &[]byte{1, 1}, + Server: &Server{ + Clients: map[uint16]*ClientConn{ + uint16(1): { + ID: &[]byte{0, 1}, + Icon: &[]byte{0, 2}, + Flags: &[]byte{0, 3}, + UserName: &[]byte{0, 4}, + }, + }, + }, + }, + t: &Transaction{ + ID: []byte{0, 0, 0, 1}, + Type: []byte{0, 1}, + }, + }, + want: []Transaction{ + { + clientID: &[]byte{1, 1}, + Flags: 0x00, + IsReply: 0x01, + Type: []byte{0, 1}, + ID: []byte{0, 0, 0, 1}, + ErrorCode: []byte{0, 0, 0, 0}, + Fields: []Field{ + NewField( + fieldUsernameWithInfo, + []byte{00, 01, 00, 02, 00, 03, 00, 02, 00, 04}, + ), + }, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := HandleGetUserNameList(tt.args.cc, tt.args.t) + if (err != nil) != tt.wantErr { + t.Errorf("HandleGetUserNameList() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("HandleGetUserNameList() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHandleChatSend(t *testing.T) { + type args struct { + cc *ClientConn + t *Transaction + } + tests := []struct { + name string + args args + want []Transaction + wantErr bool + }{ + { + name: "sends chat msg transaction to all clients", + args: args{ + cc: &ClientConn{ + UserName: &[]byte{0x00, 0x01}, + Server: &Server{ + Clients: map[uint16]*ClientConn{ + uint16(1): { + Account: &Account{ + Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: &[]byte{0, 1}, + }, + uint16(2): { + Account: &Account{ + Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: &[]byte{0, 2}, + }, + }, + }, + }, + t: &Transaction{ + Fields: []Field{ + NewField(fieldData, []byte("hai")), + }, + }, + }, + want: []Transaction{ + { + clientID: &[]byte{0, 1}, + Flags: 0x00, + IsReply: 0x00, + Type: []byte{0, 0x6a}, + ID: []byte{0x9a, 0xcb, 0x04, 0x42}, // Random ID from rand.Seed(1) + ErrorCode: []byte{0, 0, 0, 0}, + Fields: []Field{ + NewField(fieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), + }, + }, + { + clientID: &[]byte{0, 2}, + Flags: 0x00, + IsReply: 0x00, + Type: []byte{0, 0x6a}, + ID: []byte{0xf0, 0xc5, 0x34, 0x1e}, // Random ID from rand.Seed(1) + ErrorCode: []byte{0, 0, 0, 0}, + Fields: []Field{ + NewField(fieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), + }, + }, + }, + wantErr: false, + }, + { + name: "only sends chat msg to clients with accessReadChat permission", + args: args{ + cc: &ClientConn{ + UserName: &[]byte{0x00, 0x01}, + Server: &Server{ + Clients: map[uint16]*ClientConn{ + uint16(1): { + Account: &Account{ + Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: &[]byte{0, 1}, + }, + uint16(2): { + Account: &Account{ + Access: &[]byte{0, 0, 0, 0, 0, 0, 0, 0}, + }, + ID: &[]byte{0, 2}, + }, + }, + }, + }, + t: &Transaction{ + Fields: []Field{ + NewField(fieldData, []byte("hai")), + }, + }, + }, + want: []Transaction{ + { + clientID: &[]byte{0, 1}, + Flags: 0x00, + IsReply: 0x00, + Type: []byte{0, 0x6a}, + ID: []byte{0x9a, 0xcb, 0x04, 0x42}, // Random ID from rand.Seed(1) + ErrorCode: []byte{0, 0, 0, 0}, + Fields: []Field{ + NewField(fieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), + }, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + rand.Seed(1) // reset seed between tests to make transaction IDs predictable + t.Run(tt.name, func(t *testing.T) { + got, err := HandleChatSend(tt.args.cc, tt.args.t) + + if (err != nil) != tt.wantErr { + t.Errorf("HandleChatSend() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !assert.Equal(t, tt.want, got) { + t.Errorf("HandleChatSend() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/hotline/transaction_test.go b/hotline/transaction_test.go new file mode 100644 index 0000000..227b9e0 --- /dev/null +++ b/hotline/transaction_test.go @@ -0,0 +1,176 @@ +package hotline + +import ( + "github.com/stretchr/testify/assert" + "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: []byte{0x00, 0x65}, + FieldSize: []byte{0x00, 0x04}, + Data: []byte{0x01, 0x02, 0x03, 0x04}, + }, + { + ID: []byte{0x00, 0x66}, + FieldSize: []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 TestReadTransaction(t *testing.T) { + sampleTransaction := &Transaction{ + Flags: byte(0), + IsReply: byte(0), + Type: []byte{0x000, 0x93}, + ID: []byte{0x000, 0x00, 0x00, 0x01}, + ErrorCode: []byte{0x000, 0x00, 0x00, 0x00}, + TotalSize: []byte{0x000, 0x00, 0x00, 0x08}, + DataSize: []byte{0x000, 0x00, 0x00, 0x08}, + ParamCount: []byte{0x00, 0x01}, + Fields: []Field{ + { + ID: []byte{0x00, 0x01}, + FieldSize: []byte{0x00, 0x02}, + Data: []byte{0xff, 0xff}, + }, + }, + } + + type args struct { + buf []byte + } + tests := []struct { + name string + args args + want *Transaction + want1 int + wantErr bool + }{ + { + name: "when buf contains all bytes for a single transaction", + args: args{ + buf: sampleTransaction.Payload(), + }, + want: sampleTransaction, + want1: len(sampleTransaction.Payload()), + wantErr: false, + }, + { + name: "when len(buf) is less than the length of the transaction", + args: args{ + buf: sampleTransaction.Payload()[:len(sampleTransaction.Payload()) - 1], + }, + want: nil, + want1: 0, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, got1, err := ReadTransaction(tt.args.buf) + if (err != nil) != tt.wantErr { + t.Errorf("ReadTransaction() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !assert.Equal(t, tt.want, got) { + t.Errorf("ReadTransaction() got = %v, want %v", got, tt.want) + } + if got1 != tt.want1 { + t.Errorf("ReadTransaction() got1 = %v, want %v", got1, tt.want1) + } + }) + } +} diff --git a/hotline/transfer.go b/hotline/transfer.go new file mode 100644 index 0000000..ecbe324 --- /dev/null +++ b/hotline/transfer.go @@ -0,0 +1,49 @@ +package hotline + +import ( + "bytes" + "encoding/binary" + "errors" +) + +type Transfer struct { + Protocol [4]byte // "HTXF" 0x48545846 + ReferenceNumber [4]byte // Unique ID generated for the transfer + DataSize [4]byte // File size + RSVD [4]byte // Not implemented in Hotline Protocol +} + +func NewReadTransfer(b []byte) (Transfer, error) { + r := bytes.NewReader(b) + var transfer Transfer + + if err := binary.Read(r, binary.BigEndian, &transfer); err != nil { + return transfer, err + } + + // 0x48545846 (HTXF) is the only supported transfer protocol + if transfer.Protocol != [4]byte{0x48, 0x54, 0x58, 0x46} { + return transfer, errors.New("invalid protocol") + } + + return transfer, nil +} +// +//type FolderTransfer struct { +// Protocol [4]byte // "HTXF" 0x48545846 +// ReferenceNumber [4]byte // Unique ID generated for the transfer +// DataSize [4]byte // File size +// RSVD [4]byte // Not implemented in Hotline Protocol +// Action [2]byte // Next file action +//} +// +//func ReadFolderTransfer(b []byte) (FolderTransfer, error) { +// r := bytes.NewReader(b) +// var decodedEvent FolderTransfer +// +// if err := binary.Read(r, binary.BigEndian, &decodedEvent); err != nil { +// return decodedEvent, err +// } +// +// return decodedEvent, nil +//} diff --git a/hotline/transfer_test.go b/hotline/transfer_test.go new file mode 100644 index 0000000..52044b0 --- /dev/null +++ b/hotline/transfer_test.go @@ -0,0 +1,7 @@ +package hotline + +import "testing" + +func TestReadTransfer(t *testing.T) { + +} diff --git a/hotline/user.go b/hotline/user.go new file mode 100644 index 0000000..f80fd72 --- /dev/null +++ b/hotline/user.go @@ -0,0 +1,69 @@ +package hotline + +import ( + "encoding/binary" +) + +// User flags are stored as a 2 byte bitmap with the following values: +const ( + userFlagAway = 0 // User is away + userFlagAdmin = 1 // User is admin + userFlagRefusePM = 2 // User refuses private messages + userFLagRefusePChat = 3 // User refuses private chat +) + +type User struct { + ID []byte // Size 2 + Icon []byte // Size 2 + Flags []byte // Size 2 + Name string // Variable length user name +} + +func (u User) Payload() []byte { + nameLen := make([]byte, 2) + binary.BigEndian.PutUint16(nameLen, uint16(len(u.Name))) + + if len(u.Icon) == 4 { + u.Icon = u.Icon[2:] + } + + if len(u.Flags) == 4 { + u.Flags = u.Flags[2:] + } + + out := append(u.ID[:2], u.Icon[:2]...) + out = append(out, u.Flags[:2]...) + out = append(out, nameLen...) + out = append(out, u.Name...) + + return out +} + +func ReadUser(b []byte) (*User, error) { + u := &User{ + ID: b[0:2], + Icon: b[2:4], + Flags: b[4:6], + Name: string(b[8:]), + } + return u, nil +} + +// DecodeUserString decodes an obfuscated user string from a client +// e.g. 98 8a 9a 8c 8b => "guest" +func DecodeUserString(encodedString []byte) (decodedString string) { + for _, char := range encodedString { + decodedString += string(rune(255 - uint(char))) + } + return decodedString +} + +// Take a []byte of uncoded ascii as input and encode it +// TODO: change the method signature to take a string and return []byte +func NegatedUserString(encodedString []byte) string { + var decodedString string + for _, char := range encodedString { + decodedString += string(255 - uint8(char))[1:] + } + return decodedString +} diff --git a/hotline/user_test.go b/hotline/user_test.go new file mode 100644 index 0000000..dae4274 --- /dev/null +++ b/hotline/user_test.go @@ -0,0 +1,110 @@ +package hotline + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestReadUser(t *testing.T) { + type args struct { + b []byte + } + tests := []struct { + name string + args args + want *User + wantErr bool + }{ + { + name: "returns expected User struct", + args: args{ + b: []byte{ + 0x00, 0x01, + 0x07, 0xd0, + 0x00, 0x01, + 0x00, 0x03, + 0x61, 0x61, 0x61, + }, + }, + want: &User{ + ID: []byte{ + 0x00, 0x01, + }, + Icon: []byte{ + 0x07, 0xd0, + }, + Flags: []byte{ + 0x00, 0x01, + }, + Name: "aaa", + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ReadUser(tt.args.b) + if (err != nil) != tt.wantErr { + t.Errorf("ReadUser() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !assert.Equal(t, tt.want, got) { + t.Errorf("ReadUser() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDecodeUserString(t *testing.T) { + type args struct { + encodedString []byte + } + tests := []struct { + name string + args args + wantDecodedString string + }{ + { + name: "decodes bytes to guest", + args: args{ + encodedString: []byte{ + 0x98, 0x8a, 0x9a, 0x8c, 0x8b, + }, + }, + wantDecodedString: "guest", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if gotDecodedString := DecodeUserString(tt.args.encodedString); gotDecodedString != tt.wantDecodedString { + t.Errorf("DecodeUserString() = %v, want %v", gotDecodedString, tt.wantDecodedString) + } + }) + } +} + +func TestNegatedUserString(t *testing.T) { + type args struct { + encodedString []byte + } + tests := []struct { + name string + args args + want string + }{ + { + name: "encodes bytes to string", + args: args{ + encodedString: []byte("guest"), + }, + want: string([]byte{0x98, 0x8a, 0x9a, 0x8c, 0x8b}), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NegatedUserString(tt.args.encodedString); got != tt.want { + t.Errorf("NegatedUserString() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/hotline/version.go b/hotline/version.go new file mode 100644 index 0000000..d24a4bc --- /dev/null +++ b/hotline/version.go @@ -0,0 +1,3 @@ +package hotline + +const VERSION = "0.0.6" diff --git a/news.go b/news.go deleted file mode 100644 index a9e5d72..0000000 --- a/news.go +++ /dev/null @@ -1,274 +0,0 @@ -package hotline - -import ( - "bytes" - "crypto/rand" - "encoding/binary" - "log" - "sort" - "time" -) - -type ThreadedNews struct { - Categories map[string]NewsCategoryListData15 `yaml:"Categories"` -} - -type NewsCategoryListData15 struct { - Type []byte `yaml:"Type"` //Size 2 ; Bundle (2) or category (3) - Name string `yaml:"Name"` // - Articles map[uint32]*NewsArtData `yaml:"Articles"` // Optional, if Type is Category - SubCats map[string]NewsCategoryListData15 `yaml:"SubCats"` - Count []byte // Article or SubCategory count Size 2 - AddSN []byte // Size 4 - DeleteSN []byte // Size 4 - GUID []byte // Size 16 -} - -func (newscat *NewsCategoryListData15) GetNewsArtListData() NewsArtListData { - var newsArts []NewsArtList - var newsArtsPayload []byte - - for i, art := range newscat.Articles { - ID := make([]byte, 4) - binary.BigEndian.PutUint32(ID, i) - - newArt := NewsArtList{ - ID: ID, - TimeStamp: art.Date, - ParentID: art.ParentArt, - Flags: []byte{0, 0, 0, 0}, - FlavorCount: []byte{0, 0}, - Title: []byte(art.Title), - Poster: []byte(art.Poster), - ArticleSize: art.DataSize(), - } - newsArts = append(newsArts, newArt) - } - - sort.Sort(byID(newsArts)) - - for _, v := range newsArts { - newsArtsPayload = append(newsArtsPayload, v.Payload()...) - } - - nald := NewsArtListData{ - ID: []byte{0, 0, 0, 0}, - Name: []byte{}, - Description: []byte{}, - NewsArtList: newsArtsPayload, - } - - return nald -} - -// NewsArtData repsents a single news article -type NewsArtData struct { - Title string `yaml:"Title"` - Poster string `yaml:"Poster"` - Date []byte `yaml:"Date"` //size 8 - PrevArt []byte `yaml:"PrevArt"` //size 4 - NextArt []byte `yaml:"NextArt"` //size 4 - ParentArt []byte `yaml:"ParentArt"` //size 4 - FirstChildArt []byte `yaml:"FirstChildArtArt"` //size 4 - DataFlav []byte `yaml:"DataFlav"` // "text/plain" - Data string `yaml:"Data"` -} - -func (art *NewsArtData) DataSize() []byte { - dataLen := make([]byte, 2) - binary.BigEndian.PutUint16(dataLen, uint16(len(art.Data))) - - return dataLen -} - -type NewsArtListData struct { - ID []byte `yaml:"ID"` // Size 4 - Name []byte `yaml:"Name"` - Description []byte `yaml:"Description"` // not used? - NewsArtList []byte // List of articles Optional (if article count > 0) -} - -func (nald *NewsArtListData) Payload() []byte { - count := make([]byte, 4) - binary.BigEndian.PutUint32(count, uint32(len(nald.NewsArtList))) - - out := append(nald.ID, count...) - out = append(out, []byte{uint8(len(nald.Name))}...) - out = append(out, nald.Name...) - out = append(out, []byte{uint8(len(nald.Description))}...) - out = append(out, nald.Description...) - out = append(out, nald.NewsArtList...) - - return out -} - -// NewsArtList is a summarized ver sion of a NewArtData record for display in list view -type NewsArtList struct { - ID []byte // Size 4 - TimeStamp []byte // Year (2 bytes), milliseconds (2 bytes) and seconds (4 bytes) - ParentID []byte // Size 4 - Flags []byte // Size 4 - FlavorCount []byte // Size 2 - // Title size 1 - Title []byte // string - // Poster size 1 - // Poster Poster string - Poster []byte - FlavorList []NewsFlavorList - // Flavor list… Optional (if flavor count > 0) - ArticleSize []byte // Size 2 -} - -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) -} - -func (nal *NewsArtList) Payload() []byte { - out := append(nal.ID, nal.TimeStamp...) - out = append(out, nal.ParentID...) - out = append(out, nal.Flags...) - - out = append(out, []byte{0, 1}...) - - out = append(out, []byte{uint8(len(nal.Title))}...) - out = append(out, nal.Title...) - out = append(out, []byte{uint8(len(nal.Poster))}...) - out = append(out, nal.Poster...) - out = append(out, []byte{0x0a, 0x74, 0x65, 0x78, 0x74, 0x2f, 0x70, 0x6c, 0x61, 0x69, 0x6e}...) // TODO: wat? - out = append(out, nal.ArticleSize...) - - return out -} - -type NewsFlavorList struct { - // Flavor size 1 - // Flavor text size MIME type string - // Article size 2 -} - -func (newscat *NewsCategoryListData15) Payload() []byte { - count := make([]byte, 2) - binary.BigEndian.PutUint16(count, uint16(len(newscat.Articles)+len(newscat.SubCats))) - - out := append(newscat.Type, count...) - - if bytes.Equal(newscat.Type, []byte{0, 3}) { - // Generate a random GUID - b := make([]byte, 16) - _, err := rand.Read(b) - if err != nil { - log.Fatal(err) - } - - out = append(out, b...) // GUID - out = append(out, []byte{0, 0, 0, 1}...) // Add SN (TODO: not sure what this is) - out = append(out, []byte{0, 0, 0, 2}...) // Delete SN (TODO: not sure what this is) - } - - out = append(out, newscat.nameLen()...) - out = append(out, []byte(newscat.Name)...) - - return out -} - -// ReadNewsCategoryListData parses a byte slice into a NewsCategoryListData15 struct -// For use on the client side -func ReadNewsCategoryListData(payload []byte) NewsCategoryListData15 { - ncld := NewsCategoryListData15{ - Type: payload[0:2], - Count: payload[2:4], - } - - if bytes.Equal(ncld.Type, []byte{0, 3}) { - ncld.GUID = payload[4:20] - ncld.AddSN = payload[20:24] - ncld.AddSN = payload[24:28] - ncld.Name = string(payload[29:]) - } else { - ncld.Name = string(payload[5:]) - } - - return ncld -} - -func (newscat *NewsCategoryListData15) nameLen() []byte { - return []byte{uint8(len(newscat.Name))} -} - -type NewsPath struct { - Paths []string -} - -func (np *NewsPath) Payload() []byte { - var out []byte - - count := make([]byte, 2) - binary.BigEndian.PutUint16(count, uint16(len(np.Paths))) - - out = append(out, count...) - for _, p := range np.Paths { - pLen := byte(len(p)) - out = append(out, []byte{0, 0}...) // what is this? - out = append(out, pLen) - out = append(out, []byte(p)...) - } - - return out -} - -func ReadNewsPath(newsPath []byte) []string { - if len(newsPath) == 0 { - return []string{} - } - 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 -} - -// News article date field contains this structure: -// Year 2 -// Milliseconds 2 (seriously?) -// Seconds 4 -func NewsDate() []byte { - t := time.Now() - ms := []byte{0, 0} - seconds := []byte{0, 0, 0, 0} - - year := []byte{0, 0} - binary.BigEndian.PutUint16(year, uint16(t.Year())) - - yearStart := time.Date(t.Year(), time.January, 1, 0, 0, 0, 0, time.Local) - - binary.BigEndian.PutUint32(seconds, uint32(t.Sub(yearStart).Seconds())) - - date := append(year, ms...) - date = append(date, seconds...) - - return date -} diff --git a/server.go b/server.go deleted file mode 100644 index e51f673..0000000 --- a/server.go +++ /dev/null @@ -1,1035 +0,0 @@ -package hotline - -import ( - "bytes" - "context" - "encoding/binary" - "errors" - "fmt" - "go.uber.org/zap" - "io" - "io/ioutil" - "log" - "math/big" - "math/rand" - "net" - "os" - "path" - "path/filepath" - "runtime/debug" - "sort" - "strings" - "sync" - "time" - - "golang.org/x/crypto/bcrypt" - "gopkg.in/yaml.v2" -) - -const ( - userIdleSeconds = 300 // time in seconds before an inactive user is marked idle - idleCheckInterval = 10 // time in seconds to check for idle users - trackerUpdateFrequency = 300 // time in seconds between tracker re-registration -) - -type Server struct { - Interface string - Port int - Accounts map[string]*Account - Agreement []byte - Clients map[uint16]*ClientConn - FlatNews []byte - ThreadedNews *ThreadedNews - FileTransfers map[uint32]*FileTransfer - Config *Config - ConfigDir string - Logger *zap.SugaredLogger - PrivateChats map[uint32]*PrivateChat - NextGuestID *uint16 - TrackerPassID []byte - Stats *Stats - - APIListener net.Listener - FileListener net.Listener - - newsReader io.Reader - newsWriter io.WriteCloser - - outbox chan Transaction - - mux sync.Mutex - flatNewsMux sync.Mutex -} - -type PrivateChat struct { - Subject string - ClientConn map[uint16]*ClientConn -} - -func (s *Server) ListenAndServe(ctx context.Context, cancelRoot context.CancelFunc) error { - s.Logger.Infow("Hotline server started", "version", VERSION) - var wg sync.WaitGroup - - wg.Add(1) - go func() { s.Logger.Fatal(s.Serve(ctx, cancelRoot, s.APIListener)) }() - - wg.Add(1) - go func() { s.Logger.Fatal(s.ServeFileTransfers(s.FileListener)) }() - - wg.Wait() - - return nil -} - -func (s *Server) APIPort() int { - return s.APIListener.Addr().(*net.TCPAddr).Port -} - -func (s *Server) ServeFileTransfers(ln net.Listener) error { - s.Logger.Infow("Hotline file transfer server started", "Addr", fmt.Sprintf(":%v", s.Port+1)) - - for { - conn, err := ln.Accept() - if err != nil { - return err - } - - go func() { - if err := s.TransferFile(conn); err != nil { - s.Logger.Errorw("file transfer error", "reason", err) - } - }() - } -} - -func (s *Server) sendTransaction(t Transaction) error { - requestNum := binary.BigEndian.Uint16(t.Type) - clientID, err := byteToInt(*t.clientID) - if err != nil { - return err - } - - s.mux.Lock() - client := s.Clients[uint16(clientID)] - s.mux.Unlock() - if client == nil { - return errors.New("invalid client") - } - userName := string(*client.UserName) - login := client.Account.Login - - handler := TransactionHandlers[requestNum] - - var n int - if n, err = client.Connection.Write(t.Payload()); err != nil { - return err - } - s.Logger.Debugw("Sent Transaction", - "name", userName, - "login", login, - "IsReply", t.IsReply, - "type", handler.Name, - "sentBytes", n, - "remoteAddr", client.Connection.RemoteAddr(), - ) - return nil -} - -func (s *Server) Serve(ctx context.Context, cancelRoot context.CancelFunc, ln net.Listener) error { - s.Logger.Infow("Hotline server started", "Addr", fmt.Sprintf(":%v", s.Port)) - - for { - conn, err := ln.Accept() - if err != nil { - s.Logger.Errorw("error accepting connection", "err", err) - } - - go func() { - for { - t := <-s.outbox - go func() { - if err := s.sendTransaction(t); err != nil { - s.Logger.Errorw("error sending transaction", "err", err) - } - }() - } - }() - go func() { - if err := s.handleNewConnection(conn); err != nil { - if err == io.EOF { - s.Logger.Infow("Client disconnected", "RemoteAddr", conn.RemoteAddr()) - } else { - s.Logger.Errorw("error serving request", "RemoteAddr", conn.RemoteAddr(), "err", err) - } - } - }() - } -} - -const ( - agreementFile = "Agreement.txt" - messageBoardFile = "MessageBoard.txt" - threadedNewsFile = "ThreadedNews.yaml" -) - -// NewServer constructs a new Server from a config dir -func NewServer(configDir, netInterface string, netPort int, logger *zap.SugaredLogger) (*Server, error) { - server := Server{ - Port: netPort, - Accounts: make(map[string]*Account), - Config: new(Config), - Clients: make(map[uint16]*ClientConn), - FileTransfers: make(map[uint32]*FileTransfer), - PrivateChats: make(map[uint32]*PrivateChat), - ConfigDir: configDir, - Logger: logger, - NextGuestID: new(uint16), - outbox: make(chan Transaction), - Stats: &Stats{StartTime: time.Now()}, - ThreadedNews: &ThreadedNews{}, - TrackerPassID: make([]byte, 4), - } - - ln, err := net.Listen("tcp", fmt.Sprintf("%s:%v", netInterface, netPort)) - if err != nil { - return nil, err - } - server.APIListener = ln - - if netPort != 0 { - netPort += 1 - } - - ln2, err := net.Listen("tcp", fmt.Sprintf("%s:%v", netInterface, netPort)) - server.FileListener = ln2 - if err != nil { - return nil, err - } - - // generate a new random passID for tracker registration - if _, err := rand.Read(server.TrackerPassID); err != nil { - return nil, err - } - - server.Logger.Debugw("Loading Agreement", "path", configDir+agreementFile) - if server.Agreement, err = os.ReadFile(configDir + agreementFile); err != nil { - return nil, err - } - - if server.FlatNews, err = os.ReadFile(configDir + "MessageBoard.txt"); err != nil { - return nil, err - } - - if err := server.loadThreadedNews(configDir + "ThreadedNews.yaml"); err != nil { - return nil, err - } - - if err := server.loadConfig(configDir + "config.yaml"); err != nil { - return nil, err - } - - if err := server.loadAccounts(configDir + "Users/"); err != nil { - return nil, err - } - - server.Config.FileRoot = configDir + "Files/" - - *server.NextGuestID = 1 - - if server.Config.EnableTrackerRegistration { - go func() { - for { - tr := TrackerRegistration{ - Port: []byte{0x15, 0x7c}, - UserCount: server.userCount(), - PassID: server.TrackerPassID, - Name: server.Config.Name, - Description: server.Config.Description, - } - for _, t := range server.Config.Trackers { - server.Logger.Infof("Registering with tracker %v", t) - - if err := register(t, tr); err != nil { - server.Logger.Errorw("unable to register with tracker %v", "error", err) - } - } - - time.Sleep(trackerUpdateFrequency * time.Second) - } - }() - } - - // 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) -} - -func (s *Server) keepaliveHandler() { - for { - time.Sleep(idleCheckInterval * time.Second) - s.mux.Lock() - - for _, c := range s.Clients { - *c.IdleTime += idleCheckInterval - if *c.IdleTime > userIdleSeconds && !c.Idle { - c.Idle = true - - flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(*c.Flags))) - flagBitmap.SetBit(flagBitmap, userFlagAway, 1) - binary.BigEndian.PutUint16(*c.Flags, uint16(flagBitmap.Int64())) - - c.sendAll( - tranNotifyChangeUser, - NewField(fieldUserID, *c.ID), - NewField(fieldUserFlags, *c.Flags), - NewField(fieldUserName, *c.UserName), - NewField(fieldUserIconID, *c.Icon), - ) - } - } - s.mux.Unlock() - } -} - -func (s *Server) writeThreadedNews() error { - s.mux.Lock() - defer s.mux.Unlock() - - out, err := yaml.Marshal(s.ThreadedNews) - if err != nil { - return err - } - err = ioutil.WriteFile( - s.ConfigDir+"ThreadedNews.yaml", - out, - 0666, - ) - return err -} - -func (s *Server) NewClientConn(conn net.Conn) *ClientConn { - s.mux.Lock() - defer s.mux.Unlock() - - clientConn := &ClientConn{ - ID: &[]byte{0, 0}, - Icon: &[]byte{0, 0}, - Flags: &[]byte{0, 0}, - UserName: &[]byte{}, - Connection: conn, - Server: s, - Version: &[]byte{}, - IdleTime: new(int), - AutoReply: &[]byte{}, - Transfers: make(map[int][]*FileTransfer), - } - *s.NextGuestID++ - ID := *s.NextGuestID - - *clientConn.IdleTime = 0 - - binary.BigEndian.PutUint16(*clientConn.ID, ID) - s.Clients[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 []byte) error { - s.mux.Lock() - defer s.mux.Unlock() - - account := Account{ - Login: login, - Name: name, - Password: hashAndSalt([]byte(password)), - Access: &access, - } - out, err := yaml.Marshal(&account) - if err != nil { - return err - } - s.Accounts[login] = &account - - return ioutil.WriteFile(s.ConfigDir+"Users/"+login+".yaml", out, 0666) -} - -// DeleteUser deletes the user account -func (s *Server) DeleteUser(login string) error { - s.mux.Lock() - defer s.mux.Unlock() - - delete(s.Accounts, login) - - return os.Remove(s.ConfigDir + "Users/" + login + ".yaml") -} - -func (s *Server) connectedUsers() []Field { - s.mux.Lock() - defer s.mux.Unlock() - - var connectedUsers []Field - for _, c := range s.Clients { - user := User{ - ID: *c.ID, - Icon: *c.Icon, - Flags: *c.Flags, - Name: string(*c.UserName), - } - connectedUsers = append(connectedUsers, NewField(fieldUsernameWithInfo, user.Payload())) - } - return connectedUsers -} - -// loadThreadedNews loads the threaded news data from disk -func (s *Server) loadThreadedNews(threadedNewsPath string) error { - fh, err := os.Open(threadedNewsPath) - if err != nil { - return err - } - decoder := yaml.NewDecoder(fh) - decoder.SetStrict(true) - - return decoder.Decode(s.ThreadedNews) -} - -// loadAccounts loads account data from disk -func (s *Server) loadAccounts(userDir string) error { - matches, err := filepath.Glob(path.Join(userDir, "*.yaml")) - if err != nil { - return err - } - - if len(matches) == 0 { - return errors.New("no user accounts found in " + userDir) - } - - for _, file := range matches { - fh, err := os.Open(file) - if err != nil { - return err - } - - account := Account{} - decoder := yaml.NewDecoder(fh) - decoder.SetStrict(true) - if err := decoder.Decode(&account); err != nil { - return err - } - - s.Accounts[account.Login] = &account - } - return nil -} - -func (s *Server) loadConfig(path string) error { - fh, err := os.Open(path) - if err != nil { - return err - } - - decoder := yaml.NewDecoder(fh) - decoder.SetStrict(true) - err = decoder.Decode(s.Config) - if err != nil { - return err - } - return nil -} - -const ( - minTransactionLen = 22 // minimum length of any transaction -) - -// handleNewConnection takes a new net.Conn and performs the initial login sequence -func (s *Server) handleNewConnection(conn net.Conn) error { - handshakeBuf := make([]byte, 12) // handshakes are always 12 bytes in length - if _, err := conn.Read(handshakeBuf); err != nil { - return err - } - if err := Handshake(conn, handshakeBuf[:12]); err != nil { - return err - } - - buf := make([]byte, 1024) - readLen, err := conn.Read(buf) - if readLen < minTransactionLen { - return err - } - if err != nil { - return err - } - - clientLogin, _, err := ReadTransaction(buf[:readLen]) - if err != nil { - return err - } - - c := s.NewClientConn(conn) - defer c.Disconnect() - defer func() { - if r := recover(); r != nil { - fmt.Println("stacktrace from panic: \n" + string(debug.Stack())) - c.Server.Logger.Errorw("PANIC", "err", r, "trace", string(debug.Stack())) - c.Disconnect() - } - }() - - encodedLogin := clientLogin.GetField(fieldUserLogin).Data - encodedPassword := clientLogin.GetField(fieldUserPassword).Data - *c.Version = clientLogin.GetField(fieldVersion).Data - - var login string - for _, char := range encodedLogin { - login += string(rune(255 - uint(char))) - } - if login == "" { - login = GuestAccount - } - - // If authentication fails, send error reply and close connection - if !c.Authenticate(login, encodedPassword) { - rep := c.NewErrReply(clientLogin, "Incorrect login.") - if _, err := conn.Write(rep.Payload()); err != nil { - return err - } - return fmt.Errorf("incorrect login") - } - - if clientLogin.GetField(fieldUserName).Data != nil { - *c.UserName = clientLogin.GetField(fieldUserName).Data - } - - if clientLogin.GetField(fieldUserIconID).Data != nil { - *c.Icon = clientLogin.GetField(fieldUserIconID).Data - } - - c.Account = c.Server.Accounts[login] - - if c.Authorize(accessDisconUser) { - *c.Flags = []byte{0, 2} - } - - s.Logger.Infow("Client connection received", "login", login, "version", *c.Version, "RemoteAddr", conn.RemoteAddr().String()) - - s.outbox <- c.NewReply(clientLogin, - NewField(fieldVersion, []byte{0x00, 0xbe}), - NewField(fieldCommunityBannerID, []byte{0x00, 0x01}), - NewField(fieldServerName, []byte(s.Config.Name)), - ) - - // Send user access privs so client UI knows how to behave - c.Server.outbox <- *NewTransaction(tranUserAccess, c.ID, NewField(fieldUserAccess, *c.Account.Access)) - - // Show agreement to client - c.Server.outbox <- *NewTransaction(tranShowAgreement, c.ID, NewField(fieldData, s.Agreement)) - - if _, err := c.notifyNewUserHasJoined(); err != nil { - return err - } - c.Server.Stats.LoginCount += 1 - - const readBuffSize = 1024000 // 1KB - TODO: what should this be? - const maxTranSize = 1024000 - tranBuff := make([]byte, 0) - tReadlen := 0 - // Infinite loop where take action on incoming client requests until the connection is closed - for { - buf = make([]byte, readBuffSize) - tranBuff = tranBuff[tReadlen:] - - readLen, err := c.Connection.Read(buf) - if err != nil { - return err - } - tranBuff = append(tranBuff, buf[:readLen]...) - - // We may have read multiple requests worth of bytes from Connection.Read. readTransactions splits them - // into a slice of transactions - var transactions []Transaction - if transactions, tReadlen, err = readTransactions(tranBuff); err != nil { - c.Server.Logger.Errorw("Error handling transaction", "err", err) - } - - // iterate over all of the transactions that were parsed from the byte slice and handle them - for _, t := range transactions { - if err := c.handleTransaction(&t); err != nil { - c.Server.Logger.Errorw("Error handling transaction", "err", err) - } - } - } -} - -func hashAndSalt(pwd []byte) string { - // Use GenerateFromPassword to hash & salt pwd. - // MinCost is just an integer constant provided by the bcrypt - // package along with DefaultCost & MaxCost. - // The cost can be any value you want provided it isn't lower - // than the MinCost (4) - hash, err := bcrypt.GenerateFromPassword(pwd, bcrypt.MinCost) - if err != nil { - log.Println(err) - } - // GenerateFromPassword returns a byte slice so we need to - // convert the bytes to a string and return it - return string(hash) -} - -// NewTransactionRef generates a random ID for the file transfer. The Hotline client includes this ID -// in the file transfer request payload, and the file transfer server will use it to map the request -// to a transfer -func (s *Server) NewTransactionRef() []byte { - transactionRef := make([]byte, 4) - rand.Read(transactionRef) - - return transactionRef -} - -func (s *Server) NewPrivateChat(cc *ClientConn) []byte { - s.mux.Lock() - defer s.mux.Unlock() - - randID := make([]byte, 4) - rand.Read(randID) - data := binary.BigEndian.Uint32(randID[:]) - - s.PrivateChats[data] = &PrivateChat{ - Subject: "", - ClientConn: make(map[uint16]*ClientConn), - } - s.PrivateChats[data].ClientConn[cc.uint16ID()] = cc - - return randID -} - -const dlFldrActionSendFile = 1 -const dlFldrActionResumeFile = 2 -const dlFldrActionNextFile = 3 - -func (s *Server) TransferFile(conn net.Conn) error { - defer func() { _ = conn.Close() }() - - buf := make([]byte, 1024) - if _, err := conn.Read(buf); err != nil { - return err - } - - t, err := NewReadTransfer(buf) - if err != nil { - return err - } - - transferRefNum := binary.BigEndian.Uint32(t.ReferenceNumber[:]) - fileTransfer := s.FileTransfers[transferRefNum] - - switch fileTransfer.Type { - case FileDownload: - fullFilePath := fmt.Sprintf("%v/%v", s.Config.FileRoot+string(fileTransfer.FilePath), string(fileTransfer.FileName)) - - ffo, err := NewFlattenedFileObject( - s.Config.FileRoot+string(fileTransfer.FilePath), - string(fileTransfer.FileName), - ) - if err != nil { - return err - } - - s.Logger.Infow("File download started", "filePath", fullFilePath, "transactionRef", fileTransfer.ReferenceNumber, "RemoteAddr", conn.RemoteAddr().String()) - - // Start by sending flat file object to client - if _, err := conn.Write(ffo.Payload()); err != nil { - return err - } - - file, err := os.Open(fullFilePath) - if err != nil { - return err - } - - sendBuffer := make([]byte, 1048576) - for { - var bytesRead int - if bytesRead, err = file.Read(sendBuffer); err == io.EOF { - break - } - - fileTransfer.BytesSent += bytesRead - - delete(s.FileTransfers, transferRefNum) - - if _, err := conn.Write(sendBuffer[:bytesRead]); err != nil { - return err - } - } - case FileUpload: - if _, err := conn.Read(buf); err != nil { - return err - } - - ffo := ReadFlattenedFileObject(buf) - payloadLen := len(ffo.Payload()) - fileSize := int(binary.BigEndian.Uint32(ffo.FlatFileDataForkHeader.DataSize)) - - destinationFile := s.Config.FileRoot + ReadFilePath(fileTransfer.FilePath) + "/" + string(fileTransfer.FileName) - s.Logger.Infow( - "File upload started", - "transactionRef", fileTransfer.ReferenceNumber, - "RemoteAddr", conn.RemoteAddr().String(), - "size", fileSize, - "dstFile", destinationFile, - ) - - newFile, err := os.Create(destinationFile) - if err != nil { - return err - } - - defer func() { _ = newFile.Close() }() - - const buffSize = 1024 - - if _, err := newFile.Write(buf[payloadLen:]); err != nil { - return err - } - receivedBytes := buffSize - payloadLen - - for { - if (fileSize - receivedBytes) < buffSize { - s.Logger.Infow( - "File upload complete", - "transactionRef", fileTransfer.ReferenceNumber, - "RemoteAddr", conn.RemoteAddr().String(), - "size", fileSize, - "dstFile", destinationFile, - ) - - if _, err := io.CopyN(newFile, conn, int64(fileSize-receivedBytes)); err != nil { - return fmt.Errorf("file transfer failed: %s", err) - } - return nil - } - - // Copy N bytes from conn to upload file - n, err := io.CopyN(newFile, conn, buffSize) - if err != nil { - return err - } - receivedBytes += int(n) - } - case FolderDownload: - // Folder Download flow: - // 1. Get filePath from the Transfer - // 2. Iterate over files - // 3. For each file: - // Send file header to client - // The client can reply in 3 ways: - // - // 1. If type is an odd number (unknown type?), or file download for the current file is completed: - // client sends []byte{0x00, 0x03} to tell the server to continue to the next file - // - // 2. If download of a file is to be resumed: - // client sends: - // []byte{0x00, 0x02} // download folder action - // [2]byte // Resume data size - // []byte file resume data (see myField_FileResumeData) - // - // 3. Otherwise download of the file is requested and client sends []byte{0x00, 0x01} - // - // When download is requested (case 2 or 3), server replies with: - // [4]byte - file size - // []byte - Flattened File Object - // - // After every file download, client could request next file with: - // []byte{0x00, 0x03} - // - // This notifies the server to send the next item header - - fh := NewFilePath(fileTransfer.FilePath) - fullFilePath := fmt.Sprintf("%v/%v", s.Config.FileRoot+fh.String(), string(fileTransfer.FileName)) - - basePathLen := len(fullFilePath) - - readBuffer := make([]byte, 1024) - - s.Logger.Infow("Start folder download", "path", fullFilePath, "ReferenceNumber", fileTransfer.ReferenceNumber, "RemoteAddr", conn.RemoteAddr()) - - i := 0 - _ = filepath.Walk(fullFilePath+"/", func(path string, info os.FileInfo, _ error) error { - i += 1 - subPath := path[basePathLen-2:] - s.Logger.Infow("Sending fileheader", "i", i, "path", path, "fullFilePath", fullFilePath, "subPath", subPath, "IsDir", info.IsDir()) - - fileHeader := NewFileHeader(subPath, info.IsDir()) - - if i == 1 { - return nil - } - - // Send the file header to client - if _, err := conn.Write(fileHeader.Payload()); err != nil { - s.Logger.Errorf("error sending file header: %v", err) - return err - } - - // Read the client's Next Action request - //TODO: Remove hardcoded behavior and switch behaviors based on the next action send - if _, err := conn.Read(readBuffer); err != nil { - s.Logger.Errorf("error reading next action: %v", err) - return err - } - - s.Logger.Infow("Client folder download action", "action", fmt.Sprintf("%X", readBuffer[0:2])) - - if info.IsDir() { - return nil - } - - splitPath := strings.Split(path, "/") - //strings.Join(splitPath[:len(splitPath)-1], "/") - - ffo, err := NewFlattenedFileObject(strings.Join(splitPath[:len(splitPath)-1], "/"), info.Name()) - if err != nil { - return err - } - s.Logger.Infow("File download started", - "fileName", info.Name(), - "transactionRef", fileTransfer.ReferenceNumber, - "RemoteAddr", conn.RemoteAddr().String(), - "TransferSize", fmt.Sprintf("%x", ffo.TransferSize()), - ) - - // Send file size to client - if _, err := conn.Write(ffo.TransferSize()); err != nil { - s.Logger.Error(err) - return err - } - - // Send file bytes to client - if _, err := conn.Write(ffo.Payload()); err != nil { - s.Logger.Error(err) - return err - } - - file, err := os.Open(path) - if err != nil { - return err - } - - sendBuffer := make([]byte, 1048576) - totalBytesSent := len(ffo.Payload()) - - for { - bytesRead, err := file.Read(sendBuffer) - if err == io.EOF { - // Read the client's Next Action request - //TODO: Remove hardcoded behavior and switch behaviors based on the next action send - if _, err := conn.Read(readBuffer); err != nil { - s.Logger.Errorf("error reading next action: %v", err) - return err - } - break - } - - sentBytes, readErr := conn.Write(sendBuffer[:bytesRead]) - totalBytesSent += sentBytes - if readErr != nil { - return err - } - } - return nil - }) - - case FolderUpload: - dstPath := s.Config.FileRoot + ReadFilePath(fileTransfer.FilePath) + "/" + string(fileTransfer.FileName) - s.Logger.Infow( - "Folder upload started", - "transactionRef", fileTransfer.ReferenceNumber, - "RemoteAddr", conn.RemoteAddr().String(), - "dstPath", dstPath, - "TransferSize", fileTransfer.TransferSize, - "FolderItemCount", fileTransfer.FolderItemCount, - ) - - // Check if the target folder exists. If not, create it. - if _, err := os.Stat(dstPath); os.IsNotExist(err) { - s.Logger.Infow("Target path does not exist; Creating...", "dstPath", dstPath) - if err := os.Mkdir(dstPath, 0777); err != nil { - s.Logger.Error(err) - } - } - - readBuffer := make([]byte, 1024) - - // Begin the folder upload flow by sending the "next file action" to client - if _, err := conn.Write([]byte{0, dlFldrActionNextFile}); err != nil { - return err - } - - fileSize := make([]byte, 4) - itemCount := binary.BigEndian.Uint16(fileTransfer.FolderItemCount) - - for i := uint16(0); i < itemCount; i++ { - if _, err := conn.Read(readBuffer); err != nil { - return err - } - fu := readFolderUpload(readBuffer) - - s.Logger.Infow( - "Folder upload continued", - "transactionRef", fmt.Sprintf("%x", fileTransfer.ReferenceNumber), - "RemoteAddr", conn.RemoteAddr().String(), - "FormattedPath", fu.FormattedPath(), - "IsFolder", fmt.Sprintf("%x", fu.IsFolder), - "PathItemCount", binary.BigEndian.Uint16(fu.PathItemCount), - ) - - if bytes.Equal(fu.IsFolder, []byte{0, 1}) { - if _, err := os.Stat(dstPath + "/" + fu.FormattedPath()); os.IsNotExist(err) { - s.Logger.Infow("Target path does not exist; Creating...", "dstPath", dstPath) - if err := os.Mkdir(dstPath+"/"+fu.FormattedPath(), 0777); err != nil { - s.Logger.Error(err) - } - } - - // Tell client to send next file - if _, err := conn.Write([]byte{0, dlFldrActionNextFile}); err != nil { - s.Logger.Error(err) - return err - } - } else { - // TODO: Check if we have the full file already. If so, send dlFldrAction_NextFile to client to skip. - // TODO: Check if we have a partial file already. If so, send dlFldrAction_ResumeFile to client to resume upload. - // Send dlFldrAction_SendFile to client to begin transfer - if _, err := conn.Write([]byte{0, dlFldrActionSendFile}); err != nil { - return err - } - - if _, err := conn.Read(fileSize); err != nil { - fmt.Println("Error reading:", err.Error()) // TODO: handle - } - - s.Logger.Infow("Starting file transfer", "fileNum", i+1, "totalFiles", itemCount, "fileSize", fileSize) - - if err := transferFile(conn, dstPath+"/"+fu.FormattedPath()); err != nil { - s.Logger.Error(err) - } - - // Tell client to send next file - if _, err := conn.Write([]byte{0, dlFldrActionNextFile}); err != nil { - s.Logger.Error(err) - return err - } - - // Client sends "MACR" after the file. Read and discard. - // TODO: This doesn't seem to be documented. What is this? Maybe resource fork? - if _, err := conn.Read(readBuffer); err != nil { - return err - } - } - } - s.Logger.Infof("Folder upload complete") - } - - return nil -} - -func transferFile(conn net.Conn, dst string) error { - const buffSize = 1024 - buf := make([]byte, buffSize) - - // Read first chunk of bytes from conn; this will be the Flat File Object and initial chunk of file bytes - if _, err := conn.Read(buf); err != nil { - return err - } - ffo := ReadFlattenedFileObject(buf) - payloadLen := len(ffo.Payload()) - fileSize := int(binary.BigEndian.Uint32(ffo.FlatFileDataForkHeader.DataSize)) - - newFile, err := os.Create(dst) - if err != nil { - return err - } - defer func() { _ = newFile.Close() }() - if _, err := newFile.Write(buf[payloadLen:]); err != nil { - return err - } - receivedBytes := buffSize - payloadLen - - for { - if (fileSize - receivedBytes) < buffSize { - _, err := io.CopyN(newFile, conn, int64(fileSize-receivedBytes)) - return err - } - - // Copy N bytes from conn to upload file - n, err := io.CopyN(newFile, conn, buffSize) - if err != nil { - return err - } - receivedBytes += int(n) - } -} - -// 00 28 // DataSize -// 00 00 // IsFolder -// 00 02 // PathItemCount -// -// 00 00 -// 09 -// 73 75 62 66 6f 6c 64 65 72 // "subfolder" -// -// 00 00 -// 15 -// 73 75 62 66 6f 6c 64 65 72 2d 74 65 73 74 66 69 6c 65 2d 35 6b // "subfolder-testfile-5k" -func readFolderUpload(buf []byte) folderUpload { - dataLen := binary.BigEndian.Uint16(buf[0:2]) - - fu := folderUpload{ - DataSize: buf[0:2], // Size of this structure (not including data size element itself) - IsFolder: buf[2:4], - PathItemCount: buf[4:6], - FileNamePath: buf[6 : dataLen+2], - } - - return fu -} - -type folderUpload struct { - DataSize []byte - IsFolder []byte - PathItemCount []byte - FileNamePath []byte -} - -func (fu *folderUpload) FormattedPath() string { - pathItemLen := binary.BigEndian.Uint16(fu.PathItemCount) - - var pathSegments []string - pathData := fu.FileNamePath - - for i := uint16(0); i < pathItemLen; i++ { - segLen := pathData[2] - pathSegments = append(pathSegments, string(pathData[3:3+segLen])) - pathData = pathData[3+segLen:] - } - - return strings.Join(pathSegments, pathSeparator) -} - -// 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[uint16]*ClientConn) (clients []*ClientConn) { - for _, c := range unsortedClients { - clients = append(clients, c) - } - sort.Sort(byClientID(clients)) - return clients -} diff --git a/server/main.go b/server/main.go index 0b9a09c..772625b 100644 --- a/server/main.go +++ b/server/main.go @@ -4,7 +4,7 @@ import ( "context" "flag" "fmt" - "github.com/jhalter/mobius" + "github.com/jhalter/mobius/hotline" "go.uber.org/zap" "go.uber.org/zap/zapcore" "os" diff --git a/server_blackbox_test.go b/server_blackbox_test.go deleted file mode 100644 index 3718bd2..0000000 --- a/server_blackbox_test.go +++ /dev/null @@ -1,321 +0,0 @@ -package hotline - -// Guest login -// Admin login is -// -//type testCase struct { -// name string // test case description -// account Account // Account struct for a user that will test transaction will execute under -// request *Transaction // transaction that will be sent by the client to the server -// setup func() // Optional test-specific setup required for the scenario -// teardown func() // Optional test-specific teardown for the scenario -// mockHandler map[int]*mockClientHandler -//} -// -//func (tt *testCase) Setup(srv *Server) error { -// if err := srv.NewUser(tt.account.Login, tt.account.Name, NegatedUserString([]byte(tt.account.Password)), *tt.account.Access); err != nil { -// return err -// } -// -// if tt.setup != nil { -// tt.setup() -// } -// -// return nil -//} -// -//func (tt *testCase) Teardown(srv *Server) error { -// if err := srv.DeleteUser(tt.account.Login); err != nil { -// return err -// } -// -// if tt.teardown != nil { -// tt.teardown() -// } -// -// return nil -//} -// -//func NewTestLogger() *zap.SugaredLogger { -// encoderCfg := zap.NewProductionEncoderConfig() -// encoderCfg.TimeKey = "timestamp" -// encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder -// -// core := zapcore.NewCore( -// zapcore.NewConsoleEncoder(encoderCfg), -// zapcore.Lock(os.Stdout), -// zap.DebugLevel, -// ) -// -// cores := []zapcore.Core{core} -// l := zap.New(zapcore.NewTee(cores...)) -// defer func() { _ = l.Sync() }() -// return l.Sugar() -//} -// -//func StartTestServer() (*Server, context.Context, context.CancelFunc) { -// ctx, cancelRoot := context.WithCancel(context.Background()) -// -// srv, err := NewServer("test/config/", "localhost", 0, NewTestLogger()) -// if err != nil { -// panic(err) -// } -// -// go func() { -// err := srv.ListenAndServe(ctx, cancelRoot) -// if err != nil { -// panic(err) -// } -// }() -// -// return srv, ctx, cancelRoot -//} -// -//func TestHandshake(t *testing.T) { -// srv, _, cancelFunc := StartTestServer() -// defer cancelFunc() -// -// port := srv.APIListener.Addr().(*net.TCPAddr).Port -// -// conn, err := net.Dial("tcp", fmt.Sprintf(":%v", port)) -// if err != nil { -// t.Fatal(err) -// } -// defer conn.Close() -// -// conn.Write([]byte{0x54, 0x52, 0x54, 0x50, 0x00, 0x01, 0x00, 0x00}) -// -// replyBuf := make([]byte, 8) -// _, _ = conn.Read(replyBuf) -// -// want := []byte{84, 82, 84, 80, 0, 0, 0, 0} -// if bytes.Compare(replyBuf, want) != 0 { -// t.Errorf("%q, want %q", replyBuf, want) -// } -// -//} -// -////func TestLogin(t *testing.T) { -//// -//// tests := []struct { -//// name string -//// client *Client -//// }{ -//// { -//// name: "when login is successful", -//// client: NewClient("guest", NewTestLogger()), -//// }, -//// } -//// for _, test := range tests { -//// t.Run(test.name, func(t *testing.T) { -//// -//// }) -//// } -////} -// -//func TestNewUser(t *testing.T) { -// srv, _, _ := StartTestServer() -// -// tests := []testCase{ -// //{ -// // name: "a valid new account", -// // mockHandler: func() mockClientHandler { -// // mh := mockClientHandler{} -// // mh.On("Handle", mock.AnythingOfType("*hotline.Client"), mock.MatchedBy(func(t *Transaction) bool { -// // println("@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@") -// // spew.Dump(t.Type) -// // spew.Dump(bytes.Equal(t.Type, []byte{0x01, 0x5e})) -// // //if !bytes.Equal(t.GetField(fieldError).Data, []byte("You are not allowed to create new accounts.")) { -// // // return false -// // //} -// // return bytes.Equal(t.Type, []byte{0x01, 0x5e}, -// // ) -// // })).Return( -// // []Transaction{}, nil, -// // ) -// // -// // clientHandlers[tranNewUser] = mh -// // return mh -// // }(), -// // client: func() *Client { -// // c := NewClient("testUser", NewTestLogger()) -// // return c -// // }(), -// // teardown: func() { -// // _ = srv.DeleteUser("testUser") -// // }, -// // account: Account{ -// // Login: "test", -// // Name: "unnamed", -// // Password: "test", -// // Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, -// // }, -// // request: NewTransaction( -// // tranNewUser, nil, -// // NewField(fieldUserLogin, []byte(NegatedUserString([]byte("testUser")))), -// // NewField(fieldUserName, []byte("testUserName")), -// // NewField(fieldUserPassword, []byte(NegatedUserString([]byte("testPw")))), -// // NewField(fieldUserAccess, []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}), -// // ), -// // want: &Transaction{ -// // Fields: []Field{}, -// // }, -// //}, -// //{ -// // name: "a newUser request from a user without the required access", -// // mockHandler: func() *mockClientHandler { -// // mh := mockClientHandler{} -// // mh.On("Handle", mock.AnythingOfType("*hotline.Client"), mock.MatchedBy(func(t *Transaction) bool { -// // if !bytes.Equal(t.GetField(fieldError).Data, []byte("You are not allowed to create new accounts.")) { -// // return false -// // } -// // return bytes.Equal(t.Type, []byte{0x01, 0x5e}) -// // })).Return( -// // []Transaction{}, nil, -// // ) -// // return &mh -// // }(), -// // teardown: func() { -// // _ = srv.DeleteUser("testUser") -// // }, -// // account: Account{ -// // Login: "test", -// // Name: "unnamed", -// // Password: "test", -// // Access: &[]byte{0, 0, 0, 0, 0, 0, 0, 0}, -// // }, -// // request: NewTransaction( -// // tranNewUser, nil, -// // NewField(fieldUserLogin, []byte(NegatedUserString([]byte("testUser")))), -// // NewField(fieldUserName, []byte("testUserName")), -// // NewField(fieldUserPassword, []byte(NegatedUserString([]byte("testPw")))), -// // NewField(fieldUserAccess, []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}), -// // ), -// //}, -// { -// name: "when user does not have required permission", -// mockHandler: func() map[int]*mockClientHandler { -// mockHandlers := make(map[int]*mockClientHandler) -// -// mh := mockClientHandler{} -// mh.On("Handle", mock.AnythingOfType("*hotline.Client"), mock.MatchedBy(func(t *Transaction) bool { -// return t.equal(Transaction{ -// Type: []byte{0x01, 0x5e}, -// IsReply: 1, -// ErrorCode: []byte{0, 0, 0, 1}, -// Fields: []Field{ -// NewField(fieldError, []byte("You are not allowed to create new accounts.")), -// }, -// }) -// })).Return( -// []Transaction{}, nil, -// ) -// mockHandlers[tranNewUser] = &mh -// -// return mockHandlers -// }(), -// -// teardown: func() { -// _ = srv.DeleteUser("testUser") -// }, -// account: Account{ -// Login: "test", -// Name: "unnamed", -// Password: "test", -// Access: &[]byte{0, 0, 0, 0, 0, 0, 0, 0}, -// }, -// request: NewTransaction( -// tranNewUser, nil, -// NewField(fieldUserLogin, []byte(NegatedUserString([]byte("testUser")))), -// NewField(fieldUserName, []byte("testUserName")), -// NewField(fieldUserPassword, []byte(NegatedUserString([]byte("testPw")))), -// NewField(fieldUserAccess, []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}), -// ), -// }, -// -// //{ -// // name: "a request to create a user that already exists", -// // setup: func() { -// // -// // }, -// // teardown: func() { -// // _ = srv.DeleteUser("testUser") -// // }, -// // account: Account{ -// // Login: "test", -// // Name: "unnamed", -// // Password: "test", -// // Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, -// // }, -// // request: NewTransaction( -// // tranNewUser, nil, -// // NewField(fieldUserLogin, []byte(NegatedUserString([]byte("guest")))), -// // NewField(fieldUserName, []byte("testUserName")), -// // NewField(fieldUserPassword, []byte(NegatedUserString([]byte("testPw")))), -// // NewField(fieldUserAccess, []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}), -// // ), -// // want: &Transaction{ -// // Fields: []Field{ -// // NewField(fieldError, []byte("Cannot create account guest because there is already an account with that login.")), -// // }, -// // }, -// //}, -// } -// -// for _, test := range tests { -// t.Run(test.name, func(t *testing.T) { -// test.Setup(srv) -// -// // move to Setup? -// c := NewClient(test.account.Name, NewTestLogger()) -// err := c.JoinServer(fmt.Sprintf(":%v", srv.APIPort()), test.account.Login, test.account.Password) -// if err != nil { -// t.Errorf("login failed: %v", err) -// } -// // end move to Setup?? -// -// for key, value := range test.mockHandler { -// c.Handlers[uint16(key)] = value -// } -// -// // send test case request -// _ = c.Send(*test.request) -// -// //time.Sleep(1 * time.Second) -// // === -// -// transactions, _ := readN(c.Connection, 1) -// for _, t := range transactions { -// _ = c.HandleTransaction(&t) -// } -// -// // === -// -// for _, handler := range test.mockHandler { -// handler.AssertExpectations(t) -// } -// -// test.Teardown(srv) -// }) -// } -//} -// -//// equal is a utility function used only in tests that determines if transactions are equal enough -//func (t Transaction) equal(otherT Transaction) bool { -// t.ID = []byte{0, 0, 0, 0} -// otherT.ID = []byte{0, 0, 0, 0} -// -// t.TotalSize = []byte{0, 0, 0, 0} -// otherT.TotalSize = []byte{0, 0, 0, 0} -// -// t.DataSize = []byte{0, 0, 0, 0} -// otherT.DataSize = []byte{0, 0, 0, 0} -// -// t.ParamCount = []byte{0, 0} -// otherT.ParamCount = []byte{0, 0} -// -// //spew.Dump(t) -// //spew.Dump(otherT) -// -// return reflect.DeepEqual(t, otherT) -//} diff --git a/server_test.go b/server_test.go deleted file mode 100644 index 2e88202..0000000 --- a/server_test.go +++ /dev/null @@ -1,806 +0,0 @@ -package hotline - -// -//import ( -// "bytes" -// "fmt" -// "github.com/google/go-cmp/cmp" -// "io/ioutil" -// "math/big" -// "net" -// "strings" -// "sync" -// "testing" -//) -// -//type transactionTest struct { -// description string // Human understandable description -// account Account // Account struct for a user that will test transaction will execute under -// request Transaction // transaction that will be sent by the client to the server -// want Transaction // transaction that the client expects to receive in response -// setup func() // Optional setup required for the test scenario -// teardown func() // Optional teardown for test scenario -//} -// -//func (tt *transactionTest) Setup(srv *Server) error { -// if err := srv.NewUser(tt.account.Login, tt.account.Name, NegatedUserString([]byte(tt.account.Password)), tt.account.Access); err != nil { -// return err -// } -// -// if tt.setup != nil { -// tt.setup() -// } -// -// return nil -//} -// -//func (tt *transactionTest) Teardown(srv *Server) error { -// if err := srv.DeleteUser(tt.account.Login); err != nil { -// return err -// } -// -// if tt.teardown != nil { -// tt.teardown() -// } -// -// return nil -//} -// -//// StartTestServer -//func StartTestServer() (srv *Server, lnPort int) { -// hotlineServer, _ := NewServer("test/config/") -// ln, err := net.Listen("tcp", ":0") -// -// if err != nil { -// panic(err) -// } -// go func() { -// for { -// conn, _ := ln.Accept() -// go hotlineServer.HandleConnection(conn) -// } -// }() -// return hotlineServer, ln.Addr().(*net.TCPAddr).Port -//} -// -//func StartTestClient(serverPort int, login, passwd string) (*Client, error) { -// c := NewClient("") -// -// err := c.JoinServer(fmt.Sprintf(":%v", serverPort), login, passwd) -// if err != nil { -// return nil, err -// } -// -// return c, nil -//} -// -//func StartTestServerWithClients(clientCount int) ([]*Client, int) { -// _, serverPort := StartTestServer() -// -// var clients []*Client -// for i := 0; i < clientCount; i++ { -// client, err := StartTestClient(serverPort, "admin", "") -// if err != nil { -// panic(err) -// } -// clients = append(clients, client) -// } -// clients[0].ReadN(2) -// -// return clients, serverPort -//} -// - - -////func TestHandleTranAgreed(t *testing.T) { -//// clients, _ := StartTestServerWithClients(2) -//// -//// chatMsg := "Test Chat" -//// -//// // Assert that both clients should receive the user join notification -//// var wg sync.WaitGroup -//// for _, client := range clients { -//// wg.Add(1) -//// go func(wg *sync.WaitGroup, c *Client) { -//// defer wg.Done() -//// -//// receivedMsg := c.ReadTransactions()[0].GetField(fieldData).Data -//// -//// want := []byte(fmt.Sprintf("test: %s\r", chatMsg)) -//// if bytes.Compare(receivedMsg, want) != 0 { -//// t.Errorf("%q, want %q", receivedMsg, want) -//// } -//// }(&wg, client) -//// } -//// -//// trans := clients[1].ReadTransactions() -//// spew.Dump(trans) -//// -//// // Send the agreement -//// clients[1].Connection.Write( -//// NewTransaction( -//// tranAgreed, 0, -//// []Field{ -//// NewField(fieldUserName, []byte("testUser")), -//// NewField(fieldUserIconID, []byte{0x00,0x07}), -//// }, -//// ).Payload(), -//// ) -//// -//// wg.Wait() -////} -// -//func TestChatSend(t *testing.T) { -// //srvPort := StartTestServer() -// // -// //senderClient := NewClient("senderClient") -// //senderClient.JoinServer(fmt.Sprintf(":%v", srvPort), "", "") -// // -// //receiverClient := NewClient("receiverClient") -// //receiverClient.JoinServer(fmt.Sprintf(":%v", srvPort), "", "") -// -// clients, _ := StartTestServerWithClients(2) -// -// chatMsg := "Test Chat" -// -// // Both clients should receive the chatMsg -// var wg sync.WaitGroup -// for _, client := range clients { -// wg.Add(1) -// go func(wg *sync.WaitGroup, c *Client) { -// defer wg.Done() -// -// receivedMsg := c.ReadTransactions()[0].GetField(fieldData).Data -// -// want := []byte(fmt.Sprintf(" test: %s\r", chatMsg)) -// if bytes.Compare(receivedMsg, want) != 0 { -// t.Errorf("%q, want %q", receivedMsg, want) -// } -// }(&wg, client) -// } -// -// // Send the chatMsg -// clients[1].Send( -// NewTransaction( -// tranChatSend, 0, -// []Field{ -// NewField(fieldData, []byte(chatMsg)), -// }, -// ), -// ) -// -// wg.Wait() -//} -// -//func TestSetClientUserInfo(t *testing.T) { -// clients, _ := StartTestServerWithClients(2) -// -// newIcon := []byte{0x00, 0x01} -// newUserName := "newName" -// -// // Both clients should receive the chatMsg -// var wg sync.WaitGroup -// for _, client := range clients { -// wg.Add(1) -// go func(wg *sync.WaitGroup, c *Client) { -// defer wg.Done() -// -// tran := c.ReadTransactions()[0] -// -// want := []byte(newUserName) -// got := tran.GetField(fieldUserName).Data -// if bytes.Compare(got, want) != 0 { -// t.Errorf("%q, want %q", got, want) -// } -// }(&wg, client) -// } -// -// _, err := clients[1].Connection.Write( -// NewTransaction( -// tranSetClientUserInfo, 0, -// []Field{ -// NewField(fieldUserIconID, newIcon), -// NewField(fieldUserName, []byte(newUserName)), -// }, -// ).Payload(), -// ) -// if err != nil { -// t.Errorf("%v", err) -// } -// -// wg.Wait() -//} -// -//// TestSendInstantMsg tests that client A can send an instant message to client B -//// -//func TestSendInstantMsg(t *testing.T) { -// clients, _ := StartTestServerWithClients(2) -// -// instantMsg := "Test IM" -// -// var wg sync.WaitGroup -// wg.Add(1) -// go func(wg *sync.WaitGroup, c *Client) { -// defer wg.Done() -// -// tran := c.WaitForTransaction(tranServerMsg) -// -// receivedMsg := tran.GetField(fieldData).Data -// want := []byte(fmt.Sprintf("%s", instantMsg)) -// if bytes.Compare(receivedMsg, want) != 0 { -// t.Errorf("%q, want %q", receivedMsg, want) -// } -// }(&wg, clients[0]) -// -// _ = clients[1].Send( -// NewTransaction(tranGetUserNameList, 0, []Field{}), -// ) -// //connectedUsersTran := clients[1].ReadTransactions()[0] -// ////connectedUsers := connectedUsersTran.Fields[0].Data[0:2] -// //spew.Dump(connectedUsersTran.Fields) -// //firstUserID := connectedUsersTran.Fields[0].Data[0:2] -// // -// //spew.Dump(firstUserID) -// -// // Send the IM -// err := clients[1].Send( -// NewTransaction( -// tranSendInstantMsg, 0, -// []Field{ -// NewField(fieldData, []byte(instantMsg)), -// NewField(fieldUserName, clients[1].UserName), -// NewField(fieldUserID, []byte{0, 2}), -// NewField(fieldOptions, []byte{0, 1}), -// }, -// ), -// ) -// if err != nil { -// t.Error(err) -// } -// -// wg.Wait() -//} -// -//func TestOldPostNews(t *testing.T) { -// clients, _ := StartTestServerWithClients(2) -// -// newsPost := "Test News Post" -// -// var wg sync.WaitGroup -// wg.Add(1) -// go func(wg *sync.WaitGroup, c *Client) { -// defer wg.Done() -// -// receivedMsg := c.ReadTransactions()[0].GetField(fieldData).Data -// -// if strings.Contains(string(receivedMsg), newsPost) == false { -// t.Errorf("news post missing") -// } -// }(&wg, clients[0]) -// -// clients[1].Connection.Write( -// NewTransaction( -// tranOldPostNews, 0, -// []Field{ -// NewField(fieldData, []byte(newsPost)), -// }, -// ).Payload(), -// ) -// -// wg.Wait() -//} -// -//// TODO: Fixme -////func TestGetFileNameList(t *testing.T) { -//// clients, _ := StartTestServerWithClients(2) -//// -//// clients[0].Connection.Write( -//// NewTransaction( -//// tranGetFileNameList, 0, -//// []Field{}, -//// ).Payload(), -//// ) -//// -//// ts := clients[0].ReadTransactions() -//// testfileSit := ReadFileNameWithInfo(ts[0].Fields[1].Data) -//// -//// want := "testfile.sit" -//// got := testfileSit.Name -//// diff := cmp.Diff(want, got) -//// if diff != "" { -//// t.Fatalf(diff) -//// } -//// if testfileSit.Name != "testfile.sit" { -//// t.Errorf("news post missing") -//// t.Errorf("%q, want %q", testfileSit.Name, "testfile.sit") -//// } -////} -// -//func TestNewsCategoryList(t *testing.T) { -// clients, _ := StartTestServerWithClients(2) -// client := clients[0] -// -// client.Send( -// NewTransaction( -// tranGetNewsCatNameList, 0, -// []Field{}, -// ), -// ) -// -// ts := client.ReadTransactions() -// cats := ts[0].GetFields(fieldNewsCatListData15) -// -// newsCat := ReadNewsCategoryListData(cats[0].Data) -// want := "TestBundle" -// got := newsCat.Name -// diff := cmp.Diff(want, got) -// if diff != "" { -// t.Fatalf(diff) -// } -// -// newsBundle := ReadNewsCategoryListData(cats[1].Data) -// want = "TestCat" -// got = newsBundle.Name -// diff = cmp.Diff(want, got) -// if diff != "" { -// t.Fatalf(diff) -// } -//} -// -//func TestNestedNewsCategoryList(t *testing.T) { -// clients, _ := StartTestServerWithClients(2) -// client := clients[0] -// newsPath := NewsPath{ -// []string{ -// "TestBundle", -// "NestedBundle", -// }, -// } -// -// _, err := client.Connection.Write( -// NewTransaction( -// tranGetNewsCatNameList, 0, -// []Field{ -// NewField( -// fieldNewsPath, -// newsPath.Payload(), -// ), -// }, -// ).Payload(), -// ) -// if err != nil { -// t.Errorf("%v", err) -// } -// -// ts := client.ReadTransactions() -// cats := ts[0].GetFields(fieldNewsCatListData15) -// -// newsCat := ReadNewsCategoryListData(cats[0].Data) -// want := "NestedCat" -// got := newsCat.Name -// diff := cmp.Diff(want, got) -// if diff != "" { -// t.Fatalf(diff) -// } -//} -// -//func TestFileDownload(t *testing.T) { -// clients, _ := StartTestServerWithClients(2) -// client := clients[0] -// -// type want struct { -// fileSize []byte -// transferSize []byte -// waitingCount []byte -// refNum []byte -// } -// var tests = []struct { -// fileName string -// want want -// }{ -// { -// fileName: "testfile.sit", -// want: want{ -// fileSize: []byte{0x0, 0x0, 0x0, 0x13}, -// transferSize: []byte{0x0, 0x0, 0x0, 0xa1}, -// }, -// }, -// { -// fileName: "testfile.txt", -// want: want{ -// fileSize: []byte{0x0, 0x0, 0x0, 0x17}, -// transferSize: []byte{0x0, 0x0, 0x0, 0xa5}, -// }, -// }, -// } -// -// for _, test := range tests { -// _, err := client.Connection.Write( -// NewTransaction( -// tranDownloadFile, 0, -// []Field{ -// NewField(fieldFileName, []byte(test.fileName)), -// NewField(fieldFilePath, []byte("")), -// }, -// ).Payload(), -// ) -// if err != nil { -// t.Errorf("%v", err) -// } -// tran := client.ReadTransactions()[0] -// -// if got := tran.GetField(fieldFileSize).Data; bytes.Compare(got, test.want.fileSize) != 0 { -// t.Errorf("TestFileDownload: fileSize got %#v, want %#v", got, test.want.fileSize) -// } -// -// if got := tran.GetField(fieldTransferSize).Data; bytes.Compare(got, test.want.transferSize) != 0 { -// t.Errorf("TestFileDownload: fieldTransferSize: %s: got %#v, want %#v", test.fileName, got, test.want.transferSize) -// } -// } -//} -// -//func TestFileUpload(t *testing.T) { -// clients, _ := StartTestServerWithClients(2) -// client := clients[0] -// -// var tests = []struct { -// fileName string -// want Transaction -// }{ -// { -// fileName: "testfile.sit", -// want: Transaction{ -// Fields: []Field{ -// NewField(fieldRefNum, []byte{0x16, 0x3f, 0x5f, 0xf}), -// }, -// }, -// }, -// } -// -// for _, test := range tests { -// err := client.Send( -// NewTransaction( -// tranUploadFile, 0, -// []Field{ -// NewField(fieldFileName, []byte(test.fileName)), -// NewField(fieldFilePath, []byte("")), -// }, -// ), -// ) -// if err != nil { -// t.Errorf("%v", err) -// } -// tran := client.ReadTransactions()[0] -// -// for _, f := range test.want.Fields { -// got := tran.GetField(f.Uint16ID()).Data -// want := test.want.GetField(fieldRefNum).Data -// if bytes.Compare(got, want) != 0 { -// t.Errorf("xxx: yyy got %#v, want %#v", got, want) -// } -// } -// } -//} -// -//// TODO: Make canonical -//func TestNewUser(t *testing.T) { -// srv, port := StartTestServer() -// -// var tests = []struct { -// description string -// setup func() -// teardown func() -// account Account -// request Transaction -// want Transaction -// }{ -// { -// description: "a valid new account", -// teardown: func() { -// _ = srv.DeleteUser("testUser") -// }, -// account: Account{ -// Login: "test", -// Name: "unnamed", -// Password: "test", -// Access: []byte{255, 255, 255, 255, 255, 255, 255, 255}, -// }, -// request: NewTransaction( -// tranNewUser, 0, -// []Field{ -// NewField(fieldUserLogin, []byte(NegatedUserString([]byte("testUser")))), -// NewField(fieldUserName, []byte("testUserName")), -// NewField(fieldUserPassword, []byte(NegatedUserString([]byte("testPw")))), -// NewField(fieldUserAccess, []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}), -// }, -// ), -// want: Transaction{ -// Fields: []Field{}, -// }, -// }, -// { -// description: "a newUser request from a user without the required access", -// teardown: func() { -// _ = srv.DeleteUser("testUser") -// }, -// account: Account{ -// Login: "test", -// Name: "unnamed", -// Password: "test", -// Access: []byte{0, 0, 0, 0, 0, 0, 0, 0}, -// }, -// request: NewTransaction( -// tranNewUser, 0, -// []Field{ -// NewField(fieldUserLogin, []byte(NegatedUserString([]byte("testUser")))), -// NewField(fieldUserName, []byte("testUserName")), -// NewField(fieldUserPassword, []byte(NegatedUserString([]byte("testPw")))), -// NewField(fieldUserAccess, []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}), -// }, -// ), -// want: Transaction{ -// Fields: []Field{ -// NewField(fieldError, []byte("You are not allowed to create new accounts.")), -// }, -// }, -// }, -// { -// description: "a request to create a user that already exists", -// teardown: func() { -// _ = srv.DeleteUser("testUser") -// }, -// account: Account{ -// Login: "test", -// Name: "unnamed", -// Password: "test", -// Access: []byte{255, 255, 255, 255, 255, 255, 255, 255}, -// }, -// request: NewTransaction( -// tranNewUser, 0, -// []Field{ -// NewField(fieldUserLogin, []byte(NegatedUserString([]byte("guest")))), -// NewField(fieldUserName, []byte("testUserName")), -// NewField(fieldUserPassword, []byte(NegatedUserString([]byte("testPw")))), -// NewField(fieldUserAccess, []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}), -// }, -// ), -// want: Transaction{ -// Fields: []Field{ -// NewField(fieldError, []byte("Cannot create account guest because there is already an account with that login.")), -// }, -// }, -// }, -// } -// -// for _, test := range tests { -// if test.setup != nil { -// test.setup() -// } -// -// if err := srv.NewUser(test.account.Login, test.account.Name, NegatedUserString([]byte(test.account.Password)), test.account.Access); err != nil { -// t.Errorf("%v", err) -// } -// -// c := NewClient("") -// err := c.JoinServer(fmt.Sprintf(":%v", port), test.account.Login, test.account.Password) -// if err != nil { -// t.Errorf("login failed: %v", err) -// } -// -// if err := c.Send(test.request); err != nil { -// t.Errorf("%v", err) -// } -// -// tran := c.ReadTransactions()[0] -// for _, want := range test.want.Fields { -// got := tran.GetField(want.Uint16ID()) -// if bytes.Compare(got.Data, want.Data) != 0 { -// t.Errorf("%v: field mismatch: want: %#v got: %#v", test.description, want.Data, got.Data) -// } -// } -// -// srv.DeleteUser(test.account.Login) -// -// if test.teardown != nil { -// test.teardown() -// } -// } -//} -// -//func TestDeleteUser(t *testing.T) { -// srv, port := StartTestServer() -// -// var tests = []transactionTest{ -// { -// description: "a deleteUser request from a user without the required access", -// account: Account{ -// Login: "test", -// Name: "unnamed", -// Password: "test", -// Access: []byte{0, 0, 0, 0, 0, 0, 0, 0}, -// }, -// request: NewTransaction( -// tranDeleteUser, 0, -// []Field{ -// NewField(fieldUserLogin, []byte(NegatedUserString([]byte("foo")))), -// }, -// ), -// want: Transaction{ -// Fields: []Field{ -// NewField(fieldError, []byte("You are not allowed to delete accounts.")), -// }, -// }, -// }, -// { -// description: "a valid deleteUser request", -// setup: func() { -// _ = srv.NewUser("foo", "foo", "foo", []byte{0, 0, 0, 0, 0, 0, 0, 0}) -// }, -// account: Account{ -// Login: "test", -// Name: "unnamed", -// Password: "test", -// Access: []byte{255, 255, 255, 255, 255, 255, 255, 255}, -// }, -// request: NewTransaction( -// tranDeleteUser, 0, -// []Field{ -// NewField(fieldUserLogin, []byte(NegatedUserString([]byte("foo")))), -// }, -// ), -// want: Transaction{ -// Fields: []Field{}, -// }, -// }, -// } -// -// for _, test := range tests { -// test.Setup(srv) -// -// c := NewClient("") -// err := c.JoinServer(fmt.Sprintf(":%v", port), test.account.Login, test.account.Password) -// if err != nil { -// t.Errorf("login failed: %v", err) -// } -// -// if err := c.Send(test.request); err != nil { -// t.Errorf("%v", err) -// } -// -// tran := c.ReadTransactions()[0] -// for _, want := range test.want.Fields { -// got := tran.GetField(want.Uint16ID()) -// if bytes.Compare(got.Data, want.Data) != 0 { -// t.Errorf("%v: field mismatch: want: %#v got: %#v", test.description, want.Data, got.Data) -// } -// } -// -// test.Teardown(srv) -// } -//} -// -//func TestDeleteFile(t *testing.T) { -// srv, port := StartTestServer() -// -// var tests = []transactionTest{ -// { -// description: "a request without the required access", -// account: Account{ -// Login: "test", -// Name: "unnamed", -// Password: "test", -// Access: []byte{0, 0, 0, 0, 0, 0, 0, 0}, -// }, -// request: NewTransaction( -// tranDeleteFile, 0, -// []Field{ -// NewField(fieldFileName, []byte("testFile")), -// NewField(fieldFilePath, []byte("")), -// }, -// ), -// want: Transaction{ -// Fields: []Field{}, -// }, -// }, -// { -// description: "a valid deleteFile request", -// setup: func() { -// _ = ioutil.WriteFile(srv.Config.FileRoot+"testFile", []byte{0x00}, 0666) -// }, -// account: Account{ -// Login: "test", -// Name: "unnamed", -// Password: "test", -// Access: []byte{255, 255, 255, 255, 255, 255, 255, 255}, -// }, -// request: NewTransaction( -// tranDeleteFile, 0, -// []Field{ -// NewField(fieldFileName, []byte("testFile")), -// NewField(fieldFilePath, []byte("")), -// }, -// ), -// want: Transaction{ -// Fields: []Field{}, -// }, -// }, -// { -// description: "an invalid request for a file that does not exist", -// account: Account{ -// Login: "test", -// Name: "unnamed", -// Password: "test", -// Access: []byte{255, 255, 255, 255, 255, 255, 255, 255}, -// }, -// request: NewTransaction( -// tranDeleteFile, 0, -// []Field{ -// NewField(fieldFileName, []byte("testFile")), -// NewField(fieldFilePath, []byte("")), -// }, -// ), -// want: Transaction{ -// Fields: []Field{ -// NewField(fieldError, []byte("Cannot delete file testFile because it does not exist or cannot be found.")), -// }, -// }, -// }, -// } -// -// for _, test := range tests { -// test.Setup(srv) -// -// c := NewClient("") -// -// if err := c.JoinServer(fmt.Sprintf(":%v", port), test.account.Login, test.account.Password); err != nil { -// t.Errorf("login failed: %v", err) -// } -// -// if err := c.Send(test.request); err != nil { -// t.Errorf("%v", err) -// } -// -// tran := c.ReadTransactions()[0] -// for _, want := range test.want.Fields { -// got := tran.GetField(want.Uint16ID()) -// if bytes.Compare(got.Data, want.Data) != 0 { -// t.Errorf("%v: field mismatch: want: %#v got: %#v", test.description, want.Data, got.Data) -// } -// } -// -// test.Teardown(srv) -// } -//} -// -//func Test_authorize(t *testing.T) { -// accessBitmap := big.NewInt(int64(0)) -// accessBitmap.SetBit(accessBitmap, accessCreateFolder, 1) -// fmt.Printf("%v %b %x\n", accessBitmap, accessBitmap, accessBitmap) -// fmt.Printf("%b\n", 0b10000) -// -// type args struct { -// access *[]byte -// reqAccess int -// } -// tests := []struct { -// name string -// args args -// want bool -// }{ -// { -// name: "fooz", -// args: args{ -// access: &[]byte{4, 0, 0, 0, 0, 0, 0, 0x02}, -// reqAccess: accessDownloadFile, -// }, -// want: true, -// }, -// } -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.T) { -// if got := authorize(tt.args.access, tt.args.reqAccess); got != tt.want { -// t.Errorf("authorize() = %v, want %v", got, tt.want) -// } -// }) -// } -//} diff --git a/stats.go b/stats.go deleted file mode 100644 index dd8ea1d..0000000 --- a/stats.go +++ /dev/null @@ -1,33 +0,0 @@ -package hotline - -import ( - "fmt" - "time" -) - -type Stats struct { - LoginCount int `yaml:"login count"` - StartTime time.Time `yaml:"start time"` - Uptime time.Duration `yaml:"uptime"` -} - -func (s *Stats) String() string { - template := ` -Server Stats: - Start Time: %v - Uptime: %s - Login Count: %v -` - d := time.Since(s.StartTime) - d = d.Round(time.Minute) - h := d / time.Hour - d -= h * time.Hour - m := d / time.Minute - - return fmt.Sprintf( - template, - s.StartTime.Format(time.RFC1123Z), - fmt.Sprintf("%02d:%02d", h, m), - s.LoginCount, - ) -} diff --git a/test/.DS_Store b/test/.DS_Store deleted file mode 100644 index db68ea8..0000000 Binary files a/test/.DS_Store and /dev/null differ diff --git a/test/config/.DS_Store b/test/config/.DS_Store deleted file mode 100644 index b486f59..0000000 Binary files a/test/config/.DS_Store and /dev/null differ diff --git a/test/config/Agreement.txt b/test/config/Agreement.txt deleted file mode 100644 index 2a3bdb7..0000000 --- a/test/config/Agreement.txt +++ /dev/null @@ -1 +0,0 @@ -This is a server agreement. Say you agree. \ No newline at end of file diff --git a/test/config/Files/test/testfile-1k b/test/config/Files/test/testfile-1k deleted file mode 100644 index 31758a0..0000000 Binary files a/test/config/Files/test/testfile-1k and /dev/null differ diff --git a/test/config/Files/test/testfile-5k b/test/config/Files/test/testfile-5k deleted file mode 100644 index c889187..0000000 Binary files a/test/config/Files/test/testfile-5k and /dev/null differ diff --git a/test/config/Files/testdir/some-nested-file.txt b/test/config/Files/testdir/some-nested-file.txt deleted file mode 100644 index e69de29..0000000 diff --git a/test/config/Files/testfile.sit b/test/config/Files/testfile.sit deleted file mode 100644 index 8d1d542..0000000 --- a/test/config/Files/testfile.sit +++ /dev/null @@ -1 +0,0 @@ -nothing to see here \ No newline at end of file diff --git a/test/config/Files/testfile.txt b/test/config/Files/testfile.txt deleted file mode 100644 index f0607d4..0000000 --- a/test/config/Files/testfile.txt +++ /dev/null @@ -1 +0,0 @@ -Hello, I'm a test file! \ No newline at end of file diff --git a/test/config/MessageBoard.txt b/test/config/MessageBoard.txt deleted file mode 100644 index 1a2f57a..0000000 --- a/test/config/MessageBoard.txt +++ /dev/null @@ -1 +0,0 @@ -From test (Dec31 15:55): Test News Post __________________________________________________________ From test (Dec31 15:54): Test News Post __________________________________________________________ From test (Dec31 15:53): Test News Post __________________________________________________________ From test (Dec31 15:52): Test News Post __________________________________________________________ From test (Dec31 15:50): Test News Post __________________________________________________________ From test (Dec31 15:50): Test News Post __________________________________________________________ From test (Dec31 15:50): Test News Post __________________________________________________________ From test (Dec31 15:49): Test News Post __________________________________________________________ From test (Dec31 15:47): Test News Post __________________________________________________________ From test (Dec31 15:47): Test News Post __________________________________________________________ From test (Dec31 15:47): Test News Post __________________________________________________________ From test (Dec31 15:44): Test News Post __________________________________________________________ From test (Dec31 15:44): Test News Post __________________________________________________________ From test (Dec31 15:43): Test News Post __________________________________________________________ From test (Dec31 15:43): Test News Post __________________________________________________________ From test (Dec31 15:29): Test News Post __________________________________________________________ From test (Dec31 15:23): Test News Post __________________________________________________________ From test (Dec31 15:18): Test News Post __________________________________________________________ From test (Dec31 15:13): Test News Post __________________________________________________________ From test (Dec31 14:23): Test News Post __________________________________________________________ From test (Dec31 14:21): Test News Post __________________________________________________________ From test (Dec31 14:20): Test News Post __________________________________________________________ From test (Dec31 14:20): Test News Post __________________________________________________________ From test (Dec31 14:19): Test News Post __________________________________________________________ From test (Dec31 14:18): Test News Post __________________________________________________________ From test (Dec31 14:14): Test News Post __________________________________________________________ From test (Dec31 14:14): Test News Post __________________________________________________________ From test (Dec31 14:13): Test News Post __________________________________________________________ From test (Dec31 14:13): Test News Post __________________________________________________________ From test (Dec31 14:12): Test News Post __________________________________________________________ From test (Dec31 14:10): Test News Post __________________________________________________________ From test (Dec31 14:10): Test News Post __________________________________________________________ From test (Dec31 14:10): Test News Post __________________________________________________________ From test (Dec31 14:9): Test News Post __________________________________________________________ From test (Dec31 14:9): Test News Post __________________________________________________________ From test (Dec31 14:9): Test News Post __________________________________________________________ From test (Dec31 14:2): Test News Post __________________________________________________________ From test (Dec31 14:1): Test News Post __________________________________________________________ From test (Dec31 14:1): Test News Post __________________________________________________________ From test (Dec31 13:59): Test News Post __________________________________________________________ From test (Dec31 13:13): Test News Post __________________________________________________________ From test (Dec31 10:58): Test News Post __________________________________________________________ From test (Dec08 14:39): Test News Post __________________________________________________________ From test (Dec08 9:52): Test News Post __________________________________________________________ From test (Dec08 7:59): Test News Post __________________________________________________________ From test (Dec08 7:59): Test News Post __________________________________________________________ From test (Dec07 11:44): Test News Post __________________________________________________________ From test (Dec07 11:44): Test News Post __________________________________________________________ From test (Dec07 11:44): Test News Post __________________________________________________________ From test (Dec07 11:43): Test News Post __________________________________________________________ From test (Dec07 11:30): Test News Post __________________________________________________________ From test (Dec07 11:29): Test News Post __________________________________________________________ From test (Dec07 11:29): Test News Post __________________________________________________________ From test (Dec07 10:13): Test News Post __________________________________________________________ From test (Dec07 10:13): Test News Post __________________________________________________________ From test (Dec07 10:12): Test News Post __________________________________________________________ From test (Dec07 10:11): Test News Post __________________________________________________________ From test (Dec07 9:19): Test News Post __________________________________________________________ From test (Dec05 17:9): Test News Post __________________________________________________________ From test (Dec03 10:58): Test News Post __________________________________________________________ From test (Dec02 17:19): Test News Post __________________________________________________________ From test (Dec02 17:18): Test News Post __________________________________________________________ From test (Dec02 15:38): Test News Post __________________________________________________________ From test (Dec02 15:38): Test News Post __________________________________________________________ From test (Dec02 15:34): Test News Post __________________________________________________________ From test (Dec02 15:27): Test News Post __________________________________________________________ From test (Dec02 15:27): Test News Post __________________________________________________________ From test (Dec02 15:18): Test News Post __________________________________________________________ From test (Dec02 15:17): Test News Post __________________________________________________________ From test (Dec02 15:16): Test News Post __________________________________________________________ From test (Dec02 14:56): Test News Post __________________________________________________________ From test (Dec02 14:55): Test News Post __________________________________________________________ From test (Dec02 14:55): Test News Post __________________________________________________________ From test (Dec02 14:55): Test News Post __________________________________________________________ From test (Dec02 14:54): Test News Post __________________________________________________________ From test (Dec02 14:54): Test News Post __________________________________________________________ From test (Dec02 14:53): Test News Post __________________________________________________________ From test (Dec02 14:50): Test News Post __________________________________________________________ From test (Dec02 14:49): Test News Post __________________________________________________________ From test (Dec02 14:49): Test News Post __________________________________________________________ From test (Dec02 14:47): Test News Post __________________________________________________________ From test (Dec02 14:34): Test News Post __________________________________________________________ From test (Dec02 14:34): Test News Post __________________________________________________________ From test (Dec02 14:26): Test News Post __________________________________________________________ From test (Dec02 14:23): Test News Post __________________________________________________________ From test (Dec02 14:22): Test News Post __________________________________________________________ From test (Dec02 14:21): Test News Post __________________________________________________________ From test (Dec02 14:17): Test News Post __________________________________________________________ From test (Dec02 14:15): Test News Post __________________________________________________________ From test (Dec02 14:14): Test News Post __________________________________________________________ From test (Dec02 14:13): Test News Post __________________________________________________________ From test (Dec02 14:13): Test News Post __________________________________________________________ From test (Dec02 14:13): Test News Post __________________________________________________________ From test (Dec02 14:13): Test News Post __________________________________________________________ From test (Dec02 14:13): Test News Post __________________________________________________________ From test (Dec02 14:12): Test News Post __________________________________________________________ From test (Dec02 14:12): Test News Post __________________________________________________________ From test (Dec01 13:58): Test News Post __________________________________________________________ From test (Dec01 13:54): Test News Post __________________________________________________________ From test (Dec01 13:34): Test News Post __________________________________________________________ From test (Dec01 12:26): Test News Post __________________________________________________________ From test (Dec01 12:26): Test News Post __________________________________________________________ From test (Dec01 12:26): Test News Post __________________________________________________________ From test (Dec01 12:26): Test News Post __________________________________________________________ From test (Dec01 12:16): Test News Post __________________________________________________________ From test (Dec01 12:16): Test News Post __________________________________________________________ From test (Dec01 12:16): Test News Post __________________________________________________________ From test (Dec01 12:16): Test News Post __________________________________________________________ From test (Dec01 12:16): Test News Post __________________________________________________________ From test (Dec01 12:16): Test News Post __________________________________________________________ From test (Dec01 12:16): Test News Post __________________________________________________________ From test (Dec01 12:16): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:15): Test News Post __________________________________________________________ From test (Dec01 12:14): Test News Post __________________________________________________________ From test (Dec01 12:14): Test News Post __________________________________________________________ From test (Dec01 12:14): Test News Post __________________________________________________________ From test (Dec01 12:14): Test News Post __________________________________________________________ From test (Dec01 12:14): Test News Post __________________________________________________________ From test (Dec01 12:14): Test News Post __________________________________________________________ From test (Dec01 12:14): Test News Post __________________________________________________________ From test (Dec01 12:14): Test News Post __________________________________________________________ From test (Dec01 12:11): Test News Post __________________________________________________________ From test (Dec01 12:11): Test News Post __________________________________________________________ From test (Dec01 12:11): Test News Post __________________________________________________________ From test (Dec01 12:11): Test News Post __________________________________________________________ From test (Dec01 12:11): Test News Post __________________________________________________________ From test (Dec01 12:11): Test News Post __________________________________________________________ From test (Dec01 12:11): Test News Post __________________________________________________________ From test (Dec01 12:3): Test News Post __________________________________________________________ From test (Dec01 12:3): Test News Post __________________________________________________________ From test (Dec01 12:3): Test News Post __________________________________________________________ From test (Dec01 11:55): Test News Post __________________________________________________________ From test (Dec01 11:55): Test News Post __________________________________________________________ From test (Dec01 11:54): Test News Post __________________________________________________________ From test (Dec01 11:53): Test News Post __________________________________________________________ From test (Dec01 11:53): Test News Post __________________________________________________________ From test (Dec01 11:49): Test News Post __________________________________________________________ From test (Dec01 11:49): Test News Post __________________________________________________________ From test (Dec01 11:49): Test News Post __________________________________________________________ From test (Dec01 11:47): Test News Post __________________________________________________________ From test (Dec01 11:47): Test News Post __________________________________________________________ From test (Dec01 11:46): Test News Post __________________________________________________________ From test (Dec01 11:46): Test News Post __________________________________________________________ From test (Dec01 11:45): Test News Post __________________________________________________________ From test (Dec01 11:45): Test News Post __________________________________________________________ From test (Dec01 11:44): Test News Post __________________________________________________________ From test (Dec01 11:20): Test News Post __________________________________________________________ From test (Dec01 11:18): Test News Post __________________________________________________________ From test (Dec01 11:14): Test News Post __________________________________________________________ From test (Dec01 10:54): Test News Post __________________________________________________________ From test (Dec01 10:48): Test News Post __________________________________________________________ From test (Dec01 10:48): Test News Post __________________________________________________________ From test (Dec01 10:48): Test News Post __________________________________________________________ From test (Dec01 10:45): Test News Post __________________________________________________________ From test (Dec01 10:31): Test News Post __________________________________________________________ From test (Dec01 10:30): Test News Post __________________________________________________________ From test (Dec01 10:29): Test News Post __________________________________________________________ From test (Dec01 10:18): Test News Post __________________________________________________________ From test (Dec01 10:18): Test News Post __________________________________________________________ From test (Dec01 10:18): Test News Post __________________________________________________________ From test (Dec01 10:18): Test News Post __________________________________________________________ From test (Dec01 10:18): Test News Post __________________________________________________________ From test (Dec01 10:18): Test News Post __________________________________________________________ From test (Dec01 10:15): Test News Post __________________________________________________________ From test (Dec01 10:15): Test News Post __________________________________________________________ From test (Dec01 10:15): Test News Post __________________________________________________________ From test (Dec01 10:15): Test News Post __________________________________________________________ From test (Dec01 10:13): Test News Post __________________________________________________________ From test (Nov30 14:29): Test News Post __________________________________________________________ From test (Nov30 14:29): Test News Post __________________________________________________________ From test (Nov30 14:29): Test News Post __________________________________________________________ From test (Nov30 14:29): Test News Post __________________________________________________________ From test (Nov30 14:29): Test News Post __________________________________________________________ From test (Nov30 14:29): Test News Post __________________________________________________________ From test (Nov30 14:29): Test News Post __________________________________________________________ From test (Nov30 14:28): Test News Post __________________________________________________________ From test (Nov30 14:19): Test News Post __________________________________________________________ From test (Nov30 14:19): Test News Post __________________________________________________________ From test (Nov30 14:19): Test News Post __________________________________________________________ From test (Nov30 14:19): Test News Post __________________________________________________________ From (Nov30 11:42): Test News Post __________________________________________________________ From test (Nov30 11:22): Test News Post __________________________________________________________ From test (Nov30 11:22): Test News Post __________________________________________________________ From test (Nov30 11:21): Test News Post __________________________________________________________ From test (Nov30 11:18): Test News Post __________________________________________________________ From test (Nov30 11:18): Test News Post __________________________________________________________ From test (Nov30 11:18): Test News Post __________________________________________________________ From test (Nov30 11:18): Test News Post __________________________________________________________ From test (Nov30 11:17): Test News Post __________________________________________________________ From test (Nov30 11:15): Test News Post __________________________________________________________ From test (Nov30 11:13): Test News Post __________________________________________________________ From test (Nov30 11:11): Test News Post __________________________________________________________ From test (Nov30 11:11): Test News Post __________________________________________________________ From test (Nov30 11:10): Test News Post __________________________________________________________ From test (Nov30 11:8): Test News Post __________________________________________________________ From test (Nov30 11:5): Test News Post __________________________________________________________ From test (Nov30 11:2): Test News Post __________________________________________________________ From test (Nov30 11:2): Test News Post __________________________________________________________ From test (Nov30 11:1): Test News Post __________________________________________________________ From test (Nov30 11:1): Test News Post __________________________________________________________ From test (Nov30 11:1): Test News Post __________________________________________________________ From test (Nov30 11:1): Test News Post __________________________________________________________ From test (Nov30 11:1): Test News Post __________________________________________________________ From test (Nov30 11:1): Test News Post __________________________________________________________ From test (Nov30 11:0): Test News Post __________________________________________________________ From test (Nov30 10:49): Test News Post __________________________________________________________ From test (Nov30 10:49): Test News Post __________________________________________________________ From test (Nov30 10:49): Test News Post __________________________________________________________ From test (Nov30 10:49): Test News Post __________________________________________________________ From test (Nov30 10:49): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:48): Test News Post __________________________________________________________ From test (Nov30 10:45): Test News Post __________________________________________________________ From test (Nov30 10:44): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:38): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:37): Test News Post __________________________________________________________ From test (Nov30 10:36): Test News Post __________________________________________________________ From test (Nov30 10:36): Test News Post __________________________________________________________ From test (Nov30 10:36): Test News Post __________________________________________________________ From test (Nov30 10:36): Test News Post __________________________________________________________ From test (Nov30 10:36): Test News Post __________________________________________________________ From test (Nov30 10:36): Test News Post __________________________________________________________ From test (Nov30 10:36): Test News Post __________________________________________________________ From test (Nov30 10:36): Test News Post __________________________________________________________ From test (Nov30 10:36): Test News Post __________________________________________________________ From test (Nov30 10:34): Test News Post __________________________________________________________ From test (Nov30 10:34): Test News Post __________________________________________________________ From test (Nov30 10:34): Test News Post __________________________________________________________ From test (Nov30 10:34): Test News Post __________________________________________________________ From test (Nov30 10:34): Test News Post __________________________________________________________ From test (Nov30 10:34): Test News Post __________________________________________________________ From test (Nov30 10:34): Test News Post __________________________________________________________ From test (Nov30 10:34): Test News Post __________________________________________________________ From test (Nov30 10:33): Test News Post __________________________________________________________ From test (Nov30 10:33): Test News Post __________________________________________________________ From test (Nov30 10:33): Test News Post __________________________________________________________ From test (Nov30 10:33): Test News Post __________________________________________________________ From test (Nov30 10:31): Test News Post __________________________________________________________ From test (Nov30 10:29): Test News Post __________________________________________________________ From test (Nov30 10:25): Test News Post __________________________________________________________ From test (Nov30 10:25): Test News Post __________________________________________________________ From test (Nov30 10:23): Test News Post __________________________________________________________ From test (Nov30 10:23): Test News Post __________________________________________________________ From test (Nov30 10:22): Test News Post __________________________________________________________ From test (Nov30 10:21): Test News Post __________________________________________________________ From test (Nov30 10:20): Test News Post __________________________________________________________ From test (Nov30 10:19): Test News Post __________________________________________________________ From test (Nov30 10:19): Test News Post __________________________________________________________ From test (Nov30 10:19): Test News Post __________________________________________________________ From test (Nov30 10:12): Test News Post __________________________________________________________ From test (Nov30 9:59): Test News Post __________________________________________________________ From test (Nov30 9:58): Test News Post __________________________________________________________ From test (Nov30 9:58): Test News Post __________________________________________________________ From test (Nov30 9:58): Test News Post __________________________________________________________ From test (Nov30 9:58): Test News Post __________________________________________________________ From test (Nov30 9:52): Test News Post __________________________________________________________ From test (Nov30 9:52): Test News Post __________________________________________________________ From test (Nov30 9:52): Test News Post __________________________________________________________ From test (Nov30 9:52): Test News Post __________________________________________________________ From test (Nov30 9:52): Test News Post __________________________________________________________ From test (Nov30 9:52): Test News Post __________________________________________________________ From test (Nov30 9:52): Test News Post __________________________________________________________ From test (Nov30 9:51): Test News Post __________________________________________________________ From test (Nov30 9:49): Test News Post __________________________________________________________ From test (Nov30 9:48): Test News Post __________________________________________________________ From test (Nov29 17:36): Test News Post __________________________________________________________ From test (Nov29 17:35): Test News Post __________________________________________________________ From test (Nov29 17:34): Test News Post __________________________________________________________ From test (Nov29 17:33): Test News Post __________________________________________________________ From test (Nov29 17:33): Test News Post __________________________________________________________ From test (Nov29 17:33): Test News Post __________________________________________________________ From test (Nov29 17:32): Test News Post __________________________________________________________ From test (Nov29 17:24): Test News Post __________________________________________________________ From test (Nov29 17:24): Test News Post __________________________________________________________ From test (Nov29 17:24): Test News Post __________________________________________________________ From test (Nov29 17:23): Test News Post __________________________________________________________ From test (Nov29 17:23): Test News Post __________________________________________________________ From test (Nov29 17:22): Test News Post __________________________________________________________ From test (Nov29 17:22): Test News Post __________________________________________________________ From test (Nov29 17:22): Test News Post __________________________________________________________ From test (Nov29 17:22): Test News Post __________________________________________________________ From test (Nov29 17:22): Test News Post __________________________________________________________ From test (Nov29 17:13): Test News Post __________________________________________________________ From test (Nov29 17:11): Test News Post __________________________________________________________ From test (Nov29 17:11): Test News Post __________________________________________________________ From test (Nov29 17:11): Test News Post __________________________________________________________ From test (Nov29 17:9): Test News Post __________________________________________________________ From test (Nov29 17:8): Test News Post __________________________________________________________ From test (Nov29 17:8): Test News Post __________________________________________________________ From test (Nov29 17:7): Test News Post __________________________________________________________ From test (Nov29 17:5): Test News Post __________________________________________________________ From test (Nov29 16:53): Test News Post __________________________________________________________ From test (Nov29 16:52): Test News Post __________________________________________________________ From test (Nov29 16:50): Test News Post __________________________________________________________ From test (Nov29 16:50): Test News Post __________________________________________________________ From test (Nov29 16:46): Test News Post __________________________________________________________ From test (Nov29 16:29): Test News Post __________________________________________________________ From test (Nov29 16:29): Test News Post __________________________________________________________ From test (Nov29 16:28): Test News Post __________________________________________________________ From test (Nov29 16:22): Test News Post __________________________________________________________ From test (Nov29 10:55): Test News Post __________________________________________________________ From test (Nov29 10:24): Test News Post __________________________________________________________ From test (Nov28 16:6): Test News Post __________________________________________________________ From test (Nov28 16:6): Test News Post __________________________________________________________ From test (Nov28 15:46): Test News Post __________________________________________________________ From test (Nov28 15:46): Test News Post __________________________________________________________ From test (Nov28 15:46): Test News Post __________________________________________________________ From test (Nov28 15:46): Test News Post __________________________________________________________ From test (Nov28 15:46): Test News Post __________________________________________________________ From test (Nov28 15:46): Test News Post __________________________________________________________ From test (Nov28 15:45): Test News Post __________________________________________________________ From test (Nov28 15:44): Test News Post __________________________________________________________ From test (Nov28 15:44): Test News Post __________________________________________________________ From test (Nov28 15:43): Test News Post __________________________________________________________ From test (Nov28 15:19): Test News Post __________________________________________________________ From test (Nov28 15:19): Test News Post __________________________________________________________ From test (Nov28 15:18): Test News Post __________________________________________________________ From test (Nov28 15:18): Test News Post __________________________________________________________ From test (Nov28 15:13): Test News Post __________________________________________________________ From test (Nov28 15:13): Test News Post __________________________________________________________ From test (Nov28 15:12): Test News Post __________________________________________________________ From test (Nov28 14:24): Test News Post __________________________________________________________ From test (Nov28 14:13): Test News Post __________________________________________________________ From test (Nov28 14:13): Test News Post __________________________________________________________ From test (Nov28 14:12): Test News Post __________________________________________________________ From test (Nov28 14:11): Test News Post __________________________________________________________ From test (Nov28 14:10): Test News Post __________________________________________________________ From test (Nov28 14:10): Test News Post __________________________________________________________ From test (Nov28 14:10): Test News Post __________________________________________________________ From test (Nov28 14:10): Test News Post __________________________________________________________ From test (Nov28 14:10): Test News Post __________________________________________________________ From test (Nov28 14:9): Test News Post __________________________________________________________ From test (Nov28 14:9): Test News Post __________________________________________________________ From test (Nov28 14:7): Test News Post __________________________________________________________ From test (Nov28 14:7): Test News Post __________________________________________________________ From test (Nov28 14:6): Test News Post __________________________________________________________ From test (Nov28 14:6): Test News Post __________________________________________________________ From test (Nov28 14:5): Test News Post __________________________________________________________ From test (Nov28 14:4): Test News Post __________________________________________________________ From test (Nov28 14:4): Test News Post __________________________________________________________ From test (Nov28 14:4): Test News Post __________________________________________________________ From test (Nov28 14:3): Test News Post __________________________________________________________ From test (Nov28 14:2): Test News Post __________________________________________________________ From test (Nov28 14:2): Test News Post __________________________________________________________ From test (Nov28 14:1): Test News Post __________________________________________________________ From test (Nov28 14:1): Test News Post __________________________________________________________ From test (Nov28 14:1): Test News Post __________________________________________________________ From test (Nov28 14:0): Test News Post __________________________________________________________ From test (Nov28 14:0): Test News Post __________________________________________________________ From test (Nov28 13:56): Test News Post __________________________________________________________ From test (Nov28 13:56): Test News Post __________________________________________________________ From test (Nov28 13:56): Test News Post __________________________________________________________ From test (Nov28 13:55): Test News Post __________________________________________________________ From test (Nov28 13:54): Test News Post __________________________________________________________ From test (Nov28 13:51): Test News Post __________________________________________________________ From test (Nov28 13:50): Test News Post __________________________________________________________ From test (Nov28 13:45): Test News Post __________________________________________________________ From test (Nov28 13:37): Test News Post __________________________________________________________ From test (Nov28 12:37): Test News Post __________________________________________________________ From test (Nov28 12:37): Test News Post __________________________________________________________ From test (Nov28 12:37): Test News Post __________________________________________________________ From test (Nov28 12:37): Test News Post __________________________________________________________ From test (Nov28 12:34): Test News Post __________________________________________________________ From test (Nov28 12:33): Test News Post __________________________________________________________ From test (Nov28 12:33): Test News Post __________________________________________________________ From test (Nov28 12:33): Test News Post __________________________________________________________ From test (Nov28 12:32): Test News Post __________________________________________________________ From test (Nov28 12:32): Test News Post __________________________________________________________ From test (Nov28 12:21): Test News Post __________________________________________________________ From test (Nov28 12:21): Test News Post __________________________________________________________ From test (Nov28 12:21): Test News Post __________________________________________________________ From test (Nov28 12:21): Test News Post __________________________________________________________ From test (Nov28 12:20): Test News Post __________________________________________________________ From test (Nov28 12:19): Test News Post __________________________________________________________ From test (Nov28 12:19): Test News Post __________________________________________________________ From test (Nov28 12:4): Test News Post __________________________________________________________ From test (Nov28 12:1): Test News Post __________________________________________________________ From test (Nov28 11:58): Test News Post __________________________________________________________ From test (Nov28 11:58): Test News Post __________________________________________________________ From test (Nov28 11:55): Test News Post __________________________________________________________ From test (Nov28 11:54): Test News Post __________________________________________________________ From test (Nov28 11:54): Test News Post __________________________________________________________ From test (Nov28 11:52): Test News Post __________________________________________________________ From test (Nov28 11:51): Test News Post __________________________________________________________ From test (Nov28 11:48): Test News Post __________________________________________________________ From test (Nov28 11:48): Test News Post __________________________________________________________ From test (Nov28 11:47): Test News Post __________________________________________________________ From test (Nov28 11:47): Test News Post __________________________________________________________ From test (Nov28 11:47): Test News Post __________________________________________________________ From test (Nov28 11:46): Test News Post __________________________________________________________ From test (Nov28 11:46): Test News Post __________________________________________________________ From test (Nov28 11:46): Test News Post __________________________________________________________ From test (Nov28 11:42): Test News Post __________________________________________________________ From test (Nov28 11:42): Test News Post __________________________________________________________ From test (Nov28 11:38): Test News Post __________________________________________________________ From test (Nov28 11:38): Test News Post __________________________________________________________ From test (Nov28 11:37): Test News Post __________________________________________________________ From test (Nov28 11:31): Test News Post __________________________________________________________ From test (Nov28 11:31): Test News Post __________________________________________________________ From test (Nov28 11:31): Test News Post __________________________________________________________ From test (Nov28 11:31): Test News Post __________________________________________________________ From test (Nov28 11:30): Test News Post __________________________________________________________ From test (Nov28 11:30): Test News Post __________________________________________________________ From test (Nov28 11:30): Test News Post __________________________________________________________ From test (Nov28 11:30): Test News Post __________________________________________________________ From test (Nov28 11:30): Test News Post __________________________________________________________ From test (Nov28 11:28): Test News Post __________________________________________________________ From test (Nov28 11:28): Test News Post __________________________________________________________ From test (Nov28 11:28): Test News Post __________________________________________________________ From test (Nov28 11:27): Test News Post __________________________________________________________ From test (Nov28 11:27): Test News Post __________________________________________________________ From test (Nov28 11:27): Test News Post __________________________________________________________ From test (Nov28 11:26): Test News Post __________________________________________________________ From test (Nov28 11:25): Test News Post __________________________________________________________ From test (Nov28 11:24): Test News Post __________________________________________________________ From test (Nov28 11:24): Test News Post __________________________________________________________ From test (Nov28 11:23): Test News Post __________________________________________________________ From test (Nov28 11:19): Test News Post __________________________________________________________ From test (Nov28 11:15): Test News Post __________________________________________________________ From test (Nov28 11:15): Test News Post __________________________________________________________ From test (Nov28 11:9): Test News Post __________________________________________________________ From test (Nov28 11:9): Test News Post __________________________________________________________ From test (Nov28 11:8): Test News Post __________________________________________________________ From test (Nov28 11:8): Test News Post __________________________________________________________ From test (Nov28 11:7): Test News Post __________________________________________________________ From test (Nov28 11:7): Test News Post __________________________________________________________ From test (Nov28 10:58): Test News Post __________________________________________________________ From test (Nov28 10:58): Test News Post __________________________________________________________ From test (Nov28 10:58): Test News Post __________________________________________________________ From test (Nov28 10:57): Test News Post __________________________________________________________ From test (Nov28 10:57): Test News Post __________________________________________________________ From test (Nov28 10:54): Test News Post __________________________________________________________ From test (Nov28 10:54): Test News Post __________________________________________________________ From test (Nov28 10:54): Test News Post __________________________________________________________ From test (Nov28 10:53): Test News Post __________________________________________________________ From test (Nov28 10:52): Test News Post __________________________________________________________ From test (Nov28 10:48): Test News Post __________________________________________________________ From test (Nov28 10:47): Test News Post __________________________________________________________ From test (Nov28 10:47): Test News Post __________________________________________________________ From test (Nov28 10:47): Test News Post __________________________________________________________ From test (Nov28 10:47): Test News Post __________________________________________________________ From test (Nov28 10:40): Test News Post __________________________________________________________ From test (Jul12 17:20): Test News Post __________________________________________________________ From test (Jul12 17:20): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:19): Test News Post __________________________________________________________ From test (Jul12 17:18): Test News Post __________________________________________________________ From test (Jul12 17:18): Test News Post __________________________________________________________ From test (Jul12 17:18): Test News Post __________________________________________________________ From test (Jul12 17:14): Test News Post __________________________________________________________ From test (Jul12 17:13): Test News Post __________________________________________________________ From test (Jul12 17:12): Test News Post __________________________________________________________ From test (Jul12 17:12): Test News Post __________________________________________________________ From test (Jul12 17:12): Test News Post __________________________________________________________ From test (Jul12 17:12): Test News Post __________________________________________________________ From test (Jul12 16:41): Test News Post __________________________________________________________ From test (Jul12 16:29): Test News Post __________________________________________________________ From test (Jul12 16:29): Test News Post __________________________________________________________ From test (Jul12 16:29): Test News Post __________________________________________________________ From test (Jul12 16:28): Test News Post __________________________________________________________ From test (Jul12 16:27): Test News Post __________________________________________________________ From test (Jul12 16:27): Test News Post __________________________________________________________ From test (Jul12 16:26): Test News Post __________________________________________________________ From test (Jul12 16:25): Test News Post __________________________________________________________ From test (Jul12 16:24): Test News Post __________________________________________________________ From test (Jul12 16:13): Test News Post __________________________________________________________ From test (Jul12 16:12): Test News Post __________________________________________________________ From test (Jul12 16:11): Test News Post __________________________________________________________ From test (Jul12 16:10): Test News Post __________________________________________________________ From test (Jul12 16:10): Test News Post __________________________________________________________ From test (Jul12 16:0): Test News Post __________________________________________________________ From test (Jul12 15:59): Test News Post __________________________________________________________ From test (Jul12 15:58): Test News Post __________________________________________________________ From test (Jul12 15:54): Test News Post __________________________________________________________ From test (Jul12 15:53): Test News Post __________________________________________________________ From test (Jul12 15:51): Test News Post __________________________________________________________ From test (Jul12 15:48): Test News Post __________________________________________________________ From test (Jul12 15:47): Test News Post __________________________________________________________ From test (Jul12 15:38): Test News Post __________________________________________________________ From test (Jul12 15:22): Test News Post __________________________________________________________ From test (Jul12 11:36): Test News Post __________________________________________________________ From test (Jul12 11:35): Test News Post __________________________________________________________ From test (Jul12 11:31): Test News Post __________________________________________________________ From test (Jul12 11:19): Test News Post __________________________________________________________ From test (Jul12 11:19): Test News Post __________________________________________________________ From test (Jul12 11:19): Test News Post __________________________________________________________ From test (Jul12 11:18): Test News Post __________________________________________________________ From test (Jul12 10:58): Test News Post __________________________________________________________ From test (Jul12 10:52): Test News Post __________________________________________________________ From test (Jul12 10:52): Test News Post __________________________________________________________ From test (Jul12 10:51): Test News Post __________________________________________________________ From test (Jul12 10:51): Test News Post __________________________________________________________ From test (Jul12 10:51): Test News Post __________________________________________________________ From test (Jul12 10:51): Test News Post __________________________________________________________ From test (Jul12 10:50): Test News Post __________________________________________________________ From test (Jul12 10:47): Test News Post __________________________________________________________ From test (Jul11 13:25): Test News Post __________________________________________________________ From test (Jul01 17:25): Test News Post __________________________________________________________ From test (Jul01 17:25): Test News Post __________________________________________________________ From test (Jul01 9:51): Test News Post __________________________________________________________ From test (Jul01 9:51): Test News Post __________________________________________________________ From test (Jul01 9:51): Test News Post __________________________________________________________ From test (Jul01 9:51): Test News Post __________________________________________________________ From test (Jul01 9:50): Test News Post __________________________________________________________ From test (Jul01 9:49): Test News Post __________________________________________________________ From test (Jul01 9:49): Test News Post __________________________________________________________ From test (Jul01 9:49): Test News Post __________________________________________________________ From test (Jul01 9:45): Test News Post __________________________________________________________ \ No newline at end of file diff --git a/test/config/ThreadedNews.yaml b/test/config/ThreadedNews.yaml deleted file mode 100644 index 9f3fd65..0000000 --- a/test/config/ThreadedNews.yaml +++ /dev/null @@ -1,232 +0,0 @@ -Categories: - TestBundle: - Type: - - 0 - - 2 - Name: TestBundle - Articles: {} - SubCats: - NestedBundle: - Type: - - 0 - - 2 - Name: NestedBundle - Articles: {} - SubCats: - NestedCat: - Type: - - 0 - - 3 - Name: NestedCat - Articles: {} - SubCats: {} - count: [] - addsn: [] - deletesn: [] - guid: [] - count: [] - addsn: [] - deletesn: [] - guid: [] - TestSubCat: - Type: - - 0 - - 3 - Name: TestSubCat - Articles: - 1: - Title: SubCatArt - Poster: Halcyon 1.9.2 - Date: - - 7 - - 228 - - 0 - - 0 - - 0 - - 254 - - 252 - - 246 - PrevArt: - - 0 - - 0 - - 0 - - 0 - NextArt: - - 0 - - 0 - - 0 - - 0 - ParentArt: - - 0 - - 0 - - 0 - - 0 - FirstChildArtArt: - - 0 - - 0 - - 0 - - 0 - DataFlav: - - 116 - - 101 - - 120 - - 116 - - 47 - - 112 - - 108 - - 97 - - 105 - - 110 - Data: I'm an article in a subcategory! - SubCats: {} - count: [] - addsn: [] - deletesn: [] - guid: [] - count: [] - addsn: [] - deletesn: [] - guid: [] - TestCat: - Type: - - 0 - - 3 - Name: TestCat - Articles: - 1: - Title: TestArt - Poster: Halcyon 1.9.2 - Date: - - 7 - - 228 - - 0 - - 0 - - 0 - - 254 - - 252 - - 204 - PrevArt: - - 0 - - 0 - - 0 - - 0 - NextArt: - - 0 - - 0 - - 0 - - 2 - ParentArt: - - 0 - - 0 - - 0 - - 0 - FirstChildArtArt: - - 0 - - 0 - - 0 - - 2 - DataFlav: - - 116 - - 101 - - 120 - - 116 - - 47 - - 112 - - 108 - - 97 - - 105 - - 110 - Data: TestArt Body - 2: - Title: 'Re: TestArt' - Poster: Halcyon 1.9.2 - Date: - - 7 - - 228 - - 0 - - 0 - - 0 - - 254 - - 252 - - 216 - PrevArt: - - 0 - - 0 - - 0 - - 1 - NextArt: - - 0 - - 0 - - 0 - - 3 - ParentArt: - - 0 - - 0 - - 0 - - 1 - FirstChildArtArt: - - 0 - - 0 - - 0 - - 0 - DataFlav: - - 116 - - 101 - - 120 - - 116 - - 47 - - 112 - - 108 - - 97 - - 105 - - 110 - Data: I'm a reply - 3: - Title: TestArt 2 - Poster: Halcyon 1.9.2 - Date: - - 7 - - 228 - - 0 - - 0 - - 0 - - 254 - - 253 - - 6 - PrevArt: - - 0 - - 0 - - 0 - - 2 - NextArt: - - 0 - - 0 - - 0 - - 0 - ParentArt: - - 0 - - 0 - - 0 - - 0 - FirstChildArtArt: - - 0 - - 0 - - 0 - - 0 - DataFlav: - - 116 - - 101 - - 120 - - 116 - - 47 - - 112 - - 108 - - 97 - - 105 - - 110 - Data: Hello world - SubCats: {} - count: [] - addsn: [] - deletesn: [] - guid: [] diff --git a/test/config/Users/.DS_Store b/test/config/Users/.DS_Store deleted file mode 100644 index 5008ddf..0000000 Binary files a/test/config/Users/.DS_Store and /dev/null differ diff --git a/test/config/Users/admin.yaml b/test/config/Users/admin.yaml deleted file mode 100644 index 1bf656b..0000000 --- a/test/config/Users/admin.yaml +++ /dev/null @@ -1,13 +0,0 @@ -Login: admin -Name: admin -Password: $2a$04$2itGEYx8C1N5bsfRSoC9JuonS3I4YfnyVPZHLSwp7kEInRX0yoB.a -Access: -- 255 -- 255 -- 255 -- 255 -- 255 -- 255 -- 0 -- 0 - diff --git a/test/config/Users/guest.yaml b/test/config/Users/guest.yaml deleted file mode 100644 index 57117bd..0000000 --- a/test/config/Users/guest.yaml +++ /dev/null @@ -1,12 +0,0 @@ -Login: guest -Name: guest -Password: $2a$04$9P/jgLn1fR9TjSoWL.rKxuN6g.1TSpf2o6Hw.aaRuBwrWIJNwsKkS -Access: -- 125 -- 240 -- 12 -- 239 -- 171 -- 128 -- 0 -- 0 diff --git a/test/config/config.yaml b/test/config/config.yaml deleted file mode 100644 index cb7f15a..0000000 --- a/test/config/config.yaml +++ /dev/null @@ -1,7 +0,0 @@ -Name: Halcyon's Test Server -Description: Experimental Hotline server -BannerID: 1 -FileRoot: conFiles/ -EnableTrackerRegistration: false -Trackers: - - hltracker.com:5499 \ No newline at end of file diff --git a/tracker.go b/tracker.go deleted file mode 100644 index 97ca108..0000000 --- a/tracker.go +++ /dev/null @@ -1,198 +0,0 @@ -package hotline - -import ( - "bytes" - "encoding/binary" - "fmt" - "github.com/jhalter/mobius/concat" - "net" - "strconv" - "time" -) - -type TrackerRegistration struct { - Port []byte // Server’s listening UDP port number TODO: wat? - UserCount int // Number of users connected to this particular server - PassID []byte // Random number generated by the server - Name string // Server’s name - Description string // Description of the server -} - -func (tr *TrackerRegistration) Payload() []byte { - userCount := make([]byte, 2) - binary.BigEndian.PutUint16(userCount, uint16(tr.UserCount)) - - return concat.Slices( - []byte{0x00, 0x01}, - tr.Port, - userCount, - []byte{0x00, 0x00}, - tr.PassID, - []byte{uint8(len(tr.Name))}, - []byte(tr.Name), - []byte{uint8(len(tr.Description))}, - []byte(tr.Description), - ) -} - -func register(tracker string, tr TrackerRegistration) error { - conn, err := net.Dial("udp", tracker) - if err != nil { - return err - } - - if _, err := conn.Write(tr.Payload()); err != nil { - return err - } - - return nil -} - -type ServerListing struct { -} - -const trackerTimeout = 5 * time.Second - -// All string values use 8-bit ASCII character set encoding. -// Client Interface with Tracker -// After establishing a connection with tracker, the following information is sent: -// Description Size Data Note -// Magic number 4 ‘HTRK’ -// Version 2 1 or 2 Old protocol (1) or new (2) - -// Reply received from the tracker starts with a header: -// - -type TrackerHeader struct { - Protocol [4]byte // "HTRK" 0x4854524B - Version [2]byte // Old protocol (1) or new (2) -} - -//Message type 2 1 Sending list of servers -//Message data size 2 Remaining size of this request -//Number of servers 2 Number of servers in the server list -//Number of servers 2 Same as previous field -type ServerInfoHeader struct { - MsgType [2]byte // always has value of 1 - MsgDataSize [2]byte // Remaining size of request - SrvCount [2]byte // Number of servers in the server list - SrvCountDup [2]byte // Same as previous field ¯\_(ツ)_/¯ -} - -type ServerRecord struct { - IPAddr []byte - Port []byte - NumUsers []byte // Number of users connected to this particular server - Unused []byte - NameSize byte // Length of name string - Name []byte // Server’s name - DescriptionSize byte - Description []byte -} - -func GetListing(addr string) ([]ServerRecord, error) { - conn, err := net.DialTimeout("tcp", addr, trackerTimeout) - if err != nil { - return nil, err - } - //spew.Dump(conn) - _, err = conn.Write( - []byte{ - 0x48, 0x54, 0x52, 0x4B, // HTRK - 0x00, 0x01, // Version - }, - ) - if err != nil { - return nil, err - } - - totalRead := 0 - - buf := make([]byte, 4096) // handshakes are always 12 bytes in length - var readLen int - if readLen, err = conn.Read(buf); err != nil { - return nil, err - } - totalRead += readLen - - var th TrackerHeader - if err := binary.Read(bytes.NewReader(buf[:6]), binary.BigEndian, &th); err != nil { - return nil, err - } - - var info ServerInfoHeader - if err := binary.Read(bytes.NewReader(buf[6:14]), binary.BigEndian, &info); err != nil { - return nil, err - } - - payloadSize := int(binary.BigEndian.Uint16(info.MsgDataSize[:])) - - if totalRead < payloadSize { - for { - //fmt.Printf("totalRead: %v", totalRead) - //fmt.Printf("readLen: %v Payload size: %v, Server count: %x\n", readLen, payloadSize, info.SrvCount) - - if readLen, err = conn.Read(buf); err != nil { - return nil, err - } - totalRead += readLen - if totalRead >= payloadSize { - break - } - } - } - //fmt.Println("passed read") - totalSrv := int(binary.BigEndian.Uint16(info.SrvCount[:])) - - //fmt.Printf("readLen: %v Payload size: %v, Server count: %x\n", readLen, payloadSize, info.SrvCount) - srvBuf := buf[14:totalRead] - - totalRead += readLen - - var servers []ServerRecord - for { - var srv ServerRecord - n, _ := srv.Read(srvBuf) - servers = append(servers, srv) - - srvBuf = srvBuf[n:] - // fmt.Printf("srvBuf len: %v\n", len(srvBuf)) - if len(servers) == totalSrv { - return servers, nil - } - - if len(srvBuf) == 0 { - if readLen, err = conn.Read(buf); err != nil { - return nil, err - } - srvBuf = buf[8:readLen] - } - } - - return servers, nil -} - -func (s *ServerRecord) Read(b []byte) (n int, err error) { - s.IPAddr = b[0:4] - s.Port = b[4:6] - s.NumUsers = b[6:8] - s.NameSize = b[10] - nameLen := int(b[10]) - s.Name = b[11 : 11+nameLen] - s.DescriptionSize = b[11+nameLen] - s.Description = b[12+nameLen : 12+nameLen+int(s.DescriptionSize)] - - return 12 + nameLen + int(s.DescriptionSize), nil -} - -func (s *ServerRecord) PortInt() int { - data := binary.BigEndian.Uint16(s.Port) - return int(data) -} - -func (s *ServerRecord) Addr() string { - return fmt.Sprintf("%s:%s", - net.IP(s.IPAddr), - strconv.Itoa(int(binary.BigEndian.Uint16(s.Port))), - ) -} diff --git a/tracker_test.go b/tracker_test.go deleted file mode 100644 index b904462..0000000 --- a/tracker_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package hotline - -import ( - "reflect" - "testing" -) - -func TestTrackerRegistration_Payload(t *testing.T) { - type fields struct { - Port []byte - UserCount int - PassID []byte - Name string - Description string - } - tests := []struct { - name string - fields fields - want []byte - }{ - { - name: "returns expected payload bytes", - fields: fields{ - Port: []byte{0x00, 0x10}, - UserCount: 2, - PassID: []byte{0x00, 0x00, 0x00, 0x01}, - Name: "Test Serv", - Description: "Fooz", - }, - want: []byte{ - 0x00, 0x01, - 0x00, 0x10, - 0x00, 0x02, - 0x00, 0x00, - 0x00, 0x00, 0x00, 0x01, - 0x09, - 0x54, 0x65, 0x73, 0x74, 0x20, 0x53, 0x65, 0x72, 0x76, - 0x04, - 0x46, 0x6f, 0x6f, 0x7a, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tr := &TrackerRegistration{ - Port: tt.fields.Port, - UserCount: tt.fields.UserCount, - PassID: tt.fields.PassID, - Name: tt.fields.Name, - Description: tt.fields.Description, - } - if got := tr.Payload(); !reflect.DeepEqual(got, tt.want) { - t.Errorf("Payload() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/transaction.go b/transaction.go deleted file mode 100644 index 8fbdf35..0000000 --- a/transaction.go +++ /dev/null @@ -1,262 +0,0 @@ -package hotline - -import ( - "encoding/binary" - "errors" - "fmt" - "github.com/jhalter/mobius/concat" - "math/rand" - "net" -) - -const ( - tranError = 0 - tranGetMsgs = 101 - tranNewMsg = 102 - tranOldPostNews = 103 - tranServerMsg = 104 - tranChatSend = 105 - tranChatMsg = 106 - tranLogin = 107 - tranSendInstantMsg = 108 - tranShowAgreement = 109 - tranDisconnectUser = 110 - // tranDisconnectMsg = 111 TODO: implement friendly disconnect - tranInviteNewChat = 112 - tranInviteToChat = 113 - tranRejectChatInvite = 114 - tranJoinChat = 115 - tranLeaveChat = 116 - tranNotifyChatChangeUser = 117 - tranNotifyChatDeleteUser = 118 - tranNotifyChatSubject = 119 - tranSetChatSubject = 120 - tranAgreed = 121 - tranGetFileNameList = 200 - tranDownloadFile = 202 - tranUploadFile = 203 - tranNewFolder = 205 - tranDeleteFile = 204 - tranGetFileInfo = 206 - tranSetFileInfo = 207 - tranMoveFile = 208 - // tranMakeFileAlias = 209 TODO: implement file alias command - tranDownloadFldr = 210 - // tranDownloadInfo = 211 TODO: implement file transfer queue - // tranDownloadBanner = 212 TODO: figure out what this is used for - tranUploadFldr = 213 - tranGetUserNameList = 300 - tranNotifyChangeUser = 301 - tranNotifyDeleteUser = 302 - tranGetClientInfoText = 303 - tranSetClientUserInfo = 304 - tranListUsers = 348 - // tranUpdateUser = 349 TODO: implement user updates from the > 1.5 account editor - tranNewUser = 350 - tranDeleteUser = 351 - tranGetUser = 352 - tranSetUser = 353 - tranUserAccess = 354 - tranUserBroadcast = 355 - tranGetNewsCatNameList = 370 - tranGetNewsArtNameList = 371 - tranDelNewsItem = 380 - tranNewNewsFldr = 381 - tranNewNewsCat = 382 - tranGetNewsArtData = 400 - tranPostNewsArt = 410 - tranDelNewsArt = 411 - tranKeepAlive = 500 -) - -type Transaction struct { - clientID *[]byte - - Flags byte // Reserved (should be 0) - IsReply byte // Request (0) or reply (1) - Type []byte // Requested operation (user defined) - ID []byte // Unique transaction ID (must be != 0) - ErrorCode []byte // Used in the reply (user defined, 0 = no error) - TotalSize []byte // Total data size for the transaction (all parts) - DataSize []byte // Size of data in this transaction part. This allows splitting large transactions into smaller parts. - ParamCount []byte // Number of the parameters for this transaction - Fields []Field -} - -func NewTransaction(t int, clientID *[]byte, fields ...Field) *Transaction { - typeSlice := make([]byte, 2) - binary.BigEndian.PutUint16(typeSlice, uint16(t)) - - idSlice := make([]byte, 4) - binary.BigEndian.PutUint32(idSlice, rand.Uint32()) - - return &Transaction{ - clientID: clientID, - Flags: 0x00, - IsReply: 0x00, - Type: typeSlice, - ID: idSlice, - ErrorCode: []byte{0, 0, 0, 0}, - Fields: fields, - } -} - -// ReadTransaction parses a byte slice into a struct. The input slice may be shorter or longer -// that the transaction size depending on what was read from the network connection. -func ReadTransaction(buf []byte) (*Transaction, int, error) { - totalSize := binary.BigEndian.Uint32(buf[12:16]) - - // the buf may include extra bytes that are not part of the transaction - // tranLen represents the length of bytes that are part of the transaction - tranLen := int(20 + totalSize) - - if tranLen > len(buf) { - return nil, 0, errors.New("buflen too small for tranLen") - } - fields, err := ReadFields(buf[20:22], buf[22:tranLen]) - if err != nil { - return nil, 0, err - } - - return &Transaction{ - Flags: buf[0], - IsReply: buf[1], - Type: buf[2:4], - ID: buf[4:8], - ErrorCode: buf[8:12], - TotalSize: buf[12:16], - DataSize: buf[16:20], - ParamCount: buf[20:22], - Fields: fields, - }, tranLen, nil -} - -func readN(conn net.Conn, n int) ([]Transaction, error) { - buf := make([]byte, 1400) - i := 0 - for { - readLen, err := conn.Read(buf) - if err != nil { - return nil, err - } - - transactions, _, err := readTransactions(buf[:readLen]) - // spew.Fdump(os.Stderr, transactions) - if err != nil { - return nil, err - } - - i += len(transactions) - - if n == i { - return transactions, nil - } - } -} - -func readTransactions(buf []byte) ([]Transaction, int, error) { - var transactions []Transaction - - bufLen := len(buf) - - var bytesRead = 0 - for bytesRead < bufLen { - t, tReadLen, err := ReadTransaction(buf[bytesRead:]) - if err != nil { - return transactions, bytesRead, err - } - bytesRead += tReadLen - - transactions = append(transactions, *t) - } - - return transactions, bytesRead, nil -} - -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: fieldID, - FieldSize: fieldSize, - Data: buf[4 : 4+fieldSizeInt], - }) - - buf = buf[fieldSizeInt+4:] - } - - if len(buf) != 0 { - return []Field{}, fmt.Errorf("extra field bytes") - } - - return fields, nil -} - -func (t Transaction) Payload() []byte { - payloadSize := t.Size() - - fieldCount := make([]byte, 2) - binary.BigEndian.PutUint16(fieldCount, uint16(len(t.Fields))) - - var fieldPayload []byte - for _, field := range t.Fields { - fieldPayload = append(fieldPayload, field.Payload()...) - } - - return concat.Slices( - []byte{t.Flags, t.IsReply}, - t.Type, - t.ID, - t.ErrorCode, - payloadSize, - payloadSize, // this is the dataSize field, but seeming the same as totalSize - fieldCount, - fieldPayload, - ) -} - -// Size returns the total size of the transaction payload -func (t Transaction) Size() []byte { - bs := make([]byte, 4) - - fieldSize := 0 - for _, field := range t.Fields { - fieldSize += len(field.Data) + 4 - } - - binary.BigEndian.PutUint32(bs, uint32(fieldSize+2)) - - return bs -} - -func (t Transaction) GetField(id int) Field { - for _, field := range t.Fields { - if id == int(binary.BigEndian.Uint16(field.ID)) { - return field - } - } - - return Field{} -} diff --git a/transaction_handlers.go b/transaction_handlers.go deleted file mode 100644 index b06297d..0000000 --- a/transaction_handlers.go +++ /dev/null @@ -1,1577 +0,0 @@ -package hotline - -import ( - "bytes" - "encoding/binary" - "errors" - "fmt" - "github.com/davecgh/go-spew/spew" - "gopkg.in/yaml.v2" - "io/ioutil" - "math/big" - "os" - "sort" - "strings" - "time" -) - -type TransactionType struct { - Access int // Specifies access privilege required to perform the transaction - DenyMsg string // The error reply message when user does not have access - Handler func(*ClientConn, *Transaction) ([]Transaction, error) // function for handling the transaction type - Name string // Name of transaction as it will appear in logging - RequiredFields []requiredField -} - -var TransactionHandlers = map[uint16]TransactionType{ - // Server initiated - tranChatMsg: { - Name: "tranChatMsg", - }, - // Server initiated - tranNotifyChangeUser: { - Name: "tranNotifyChangeUser", - }, - tranError: { - Name: "tranError", - }, - tranShowAgreement: { - Name: "tranShowAgreement", - }, - tranUserAccess: { - Name: "tranUserAccess", - }, - tranNotifyDeleteUser: { - Name: "tranNotifyDeleteUser", - }, - tranAgreed: { - Access: accessAlwaysAllow, - Name: "tranAgreed", - Handler: HandleTranAgreed, - }, - tranChatSend: { - Access: accessSendChat, - DenyMsg: "You are not allowed to participate in chat.", - Handler: HandleChatSend, - Name: "tranChatSend", - RequiredFields: []requiredField{ - { - ID: fieldData, - minLen: 0, - }, - }, - }, - tranDelNewsArt: { - Access: accessNewsDeleteArt, - DenyMsg: "You are not allowed to delete news articles.", - Name: "tranDelNewsArt", - Handler: HandleDelNewsArt, - }, - tranDelNewsItem: { - Access: accessAlwaysAllow, // Granular access enforced inside the handler - // Has multiple access flags: News Delete Folder (37) or News Delete Category (35) - // TODO: Implement inside the handler - Name: "tranDelNewsItem", - Handler: HandleDelNewsItem, - }, - tranDeleteFile: { - Access: accessAlwaysAllow, // Granular access enforced inside the handler - Name: "tranDeleteFile", - Handler: HandleDeleteFile, - }, - tranDeleteUser: { - Access: accessDeleteUser, - DenyMsg: "You are not allowed to delete accounts.", - Name: "tranDeleteUser", - Handler: HandleDeleteUser, - }, - tranDisconnectUser: { - Access: accessDisconUser, - DenyMsg: "You are not allowed to disconnect users.", - Name: "tranDisconnectUser", - Handler: HandleDisconnectUser, - }, - tranDownloadFile: { - Access: accessDownloadFile, - DenyMsg: "You are not allowed to download files.", - Name: "tranDownloadFile", - Handler: HandleDownloadFile, - }, - tranDownloadFldr: { - Access: accessDownloadFile, // There is no specific access flag for folder vs file download - DenyMsg: "You are not allowed to download files.", - Name: "tranDownloadFldr", - Handler: HandleDownloadFolder, - }, - tranGetClientInfoText: { - Access: accessGetClientInfo, - DenyMsg: "You are not allowed to get client info", - Name: "tranGetClientInfoText", - Handler: HandleGetClientConnInfoText, - }, - tranGetFileInfo: { - Access: accessAlwaysAllow, - Name: "tranGetFileInfo", - Handler: HandleGetFileInfo, - }, - tranGetFileNameList: { - Access: accessAlwaysAllow, - Name: "tranGetFileNameList", - Handler: HandleGetFileNameList, - }, - tranGetMsgs: { - Access: accessNewsReadArt, - DenyMsg: "You are not allowed to read news.", - Name: "tranGetMsgs", - Handler: HandleGetMsgs, - }, - tranGetNewsArtData: { - Access: accessNewsReadArt, - DenyMsg: "You are not allowed to read news.", - Name: "tranGetNewsArtData", - Handler: HandleGetNewsArtData, - }, - tranGetNewsArtNameList: { - Access: accessNewsReadArt, - DenyMsg: "You are not allowed to read news.", - Name: "tranGetNewsArtNameList", - Handler: HandleGetNewsArtNameList, - }, - tranGetNewsCatNameList: { - Access: accessNewsReadArt, - DenyMsg: "You are not allowed to read news.", - Name: "tranGetNewsCatNameList", - Handler: HandleGetNewsCatNameList, - }, - tranGetUser: { - Access: accessOpenUser, - DenyMsg: "You are not allowed to view accounts.", - Name: "tranGetUser", - Handler: HandleGetUser, - }, - tranGetUserNameList: { - Access: accessAlwaysAllow, - Name: "tranHandleGetUserNameList", - Handler: HandleGetUserNameList, - }, - tranInviteNewChat: { - Access: accessOpenChat, - DenyMsg: "You are not allowed to request private chat.", - Name: "tranInviteNewChat", - Handler: HandleInviteNewChat, - }, - tranInviteToChat: { - Access: accessOpenChat, - DenyMsg: "You are not allowed to request private chat.", - Name: "tranInviteToChat", - Handler: HandleInviteToChat, - }, - tranJoinChat: { - Access: accessAlwaysAllow, - Name: "tranJoinChat", - Handler: HandleJoinChat, - }, - tranKeepAlive: { - Access: accessAlwaysAllow, - Name: "tranKeepAlive", - Handler: HandleKeepAlive, - }, - tranLeaveChat: { - Access: accessAlwaysAllow, - Name: "tranJoinChat", - Handler: HandleLeaveChat, - }, - - tranListUsers: { - Access: accessOpenUser, - DenyMsg: "You are not allowed to view accounts.", - Name: "tranListUsers", - Handler: HandleListUsers, - }, - tranMoveFile: { - Access: accessMoveFile, - DenyMsg: "You are not allowed to move files.", - Name: "tranMoveFile", - Handler: HandleMoveFile, - }, - tranNewFolder: { - Access: accessCreateFolder, - DenyMsg: "You are not allow to create folders.", - Name: "tranNewFolder", - Handler: HandleNewFolder, - }, - tranNewNewsCat: { - Access: accessNewsCreateCat, - DenyMsg: "You are not allowed to create news categories.", - Name: "tranNewNewsCat", - Handler: HandleNewNewsCat, - }, - tranNewNewsFldr: { - Access: accessNewsCreateFldr, - DenyMsg: "You are not allowed to create news folders.", - Name: "tranNewNewsFldr", - Handler: HandleNewNewsFldr, - }, - tranNewUser: { - Access: accessCreateUser, - DenyMsg: "You are not allowed to create new accounts.", - Name: "tranNewUser", - Handler: HandleNewUser, - }, - tranOldPostNews: { - Access: accessNewsPostArt, - DenyMsg: "You are not allowed to post news.", - Name: "tranOldPostNews", - Handler: HandleTranOldPostNews, - }, - tranPostNewsArt: { - Access: accessNewsPostArt, - DenyMsg: "You are not allowed to post news articles.", - Name: "tranPostNewsArt", - Handler: HandlePostNewsArt, - }, - tranRejectChatInvite: { - Access: accessAlwaysAllow, - Name: "tranRejectChatInvite", - Handler: HandleRejectChatInvite, - }, - tranSendInstantMsg: { - Access: accessAlwaysAllow, - //Access: accessSendPrivMsg, - //DenyMsg: "You are not allowed to send private messages", - Name: "tranSendInstantMsg", - Handler: HandleSendInstantMsg, - RequiredFields: []requiredField{ - { - ID: fieldData, - minLen: 0, - }, - { - ID: fieldUserID, - }, - }, - }, - tranSetChatSubject: { - Access: accessAlwaysAllow, - Name: "tranSetChatSubject", - Handler: HandleSetChatSubject, - }, - tranSetClientUserInfo: { - Access: accessAlwaysAllow, - Name: "tranSetClientUserInfo", - Handler: HandleSetClientUserInfo, - }, - tranSetFileInfo: { - Access: accessAlwaysAllow, // granular access is in the handler - Name: "tranSetFileInfo", - Handler: HandleSetFileInfo, - }, - tranSetUser: { - Access: accessModifyUser, - DenyMsg: "You are not allowed to modify accounts.", - Name: "tranSetUser", - Handler: HandleSetUser, - }, - tranUploadFile: { - Access: accessUploadFile, - DenyMsg: "You are not allowed to upload files.", - Name: "tranUploadFile", - Handler: HandleUploadFile, - }, - tranUploadFldr: { - Access: accessAlwaysAllow, // TODO: what should this be? - Name: "tranUploadFldr", - Handler: HandleUploadFolder, - }, - tranUserBroadcast: { - Access: accessBroadcast, - DenyMsg: "You are not allowed to send broadcast messages.", - Name: "tranUserBroadcast", - Handler: HandleUserBroadcast, - }, -} - -func HandleChatSend(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - // Truncate long usernames - trunc := fmt.Sprintf("%13s", *cc.UserName) - formattedMsg := fmt.Sprintf("\r%.14s: %s", trunc, t.GetField(fieldData).Data) - - // By holding the option key, Hotline chat allows users to send /me formatted messages like: - // *** Halcyon does stuff - // This is indicated by the presence of the optional field fieldChatOptions in the transaction payload - if t.GetField(fieldChatOptions).Data != nil { - formattedMsg = fmt.Sprintf("*** %s %s\r", *cc.UserName, t.GetField(fieldData).Data) - } - - if bytes.Equal(t.GetField(fieldData).Data, []byte("/stats")) { - formattedMsg = strings.Replace(cc.Server.Stats.String(), "\n", "\r", -1) - } - - chatID := t.GetField(fieldChatID).Data - // a non-nil chatID indicates the message belongs to a private chat - if chatID != nil { - chatInt := binary.BigEndian.Uint32(chatID) - privChat := cc.Server.PrivateChats[chatInt] - - // send the message to all connected clients of the private chat - for _, c := range privChat.ClientConn { - res = append(res, *NewTransaction( - tranChatMsg, - c.ID, - NewField(fieldChatID, chatID), - NewField(fieldData, []byte(formattedMsg)), - )) - } - return res, err - } - - for _, c := range sortedClients(cc.Server.Clients) { - // Filter out clients that do not have the read chat permission - if authorize(c.Account.Access, accessReadChat) { - res = append(res, *NewTransaction(tranChatMsg, c.ID, NewField(fieldData, []byte(formattedMsg)))) - } - } - - return res, err -} - -// HandleSendInstantMsg sends instant message to the user on the current server. -// Fields used in the request: -// 103 User ID -// 113 Options -// One of the following values: -// - User message (myOpt_UserMessage = 1) -// - Refuse message (myOpt_RefuseMessage = 2) -// - Refuse chat (myOpt_RefuseChat = 3) -// - Automatic response (myOpt_AutomaticResponse = 4)" -// 101 Data Optional -// 214 Quoting message Optional -// -//Fields used in the reply: -// None -func HandleSendInstantMsg(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - msg := t.GetField(fieldData) - ID := t.GetField(fieldUserID) - // TODO: Implement reply quoting - //options := transaction.GetField(hotline.fieldOptions) - - res = append(res, - *NewTransaction( - tranServerMsg, - &ID.Data, - NewField(fieldData, msg.Data), - NewField(fieldUserName, *cc.UserName), - NewField(fieldUserID, *cc.ID), - NewField(fieldOptions, []byte{0, 1}), - ), - ) - id, _ := byteToInt(ID.Data) - - //keys := make([]uint16, 0, len(cc.Server.Clients)) - //for k := range cc.Server.Clients { - // keys = append(keys, k) - //} - - otherClient := cc.Server.Clients[uint16(id)] - if otherClient == nil { - return res, errors.New("ohno") - } - - // Respond with auto reply if other client has it enabled - if len(*otherClient.AutoReply) > 0 { - res = append(res, - *NewTransaction( - tranServerMsg, - cc.ID, - NewField(fieldData, *otherClient.AutoReply), - NewField(fieldUserName, *otherClient.UserName), - NewField(fieldUserID, *otherClient.ID), - NewField(fieldOptions, []byte{0, 1}), - ), - ) - } - - res = append(res, cc.NewReply(t)) - - return res, err -} - -func HandleGetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - fileName := string(t.GetField(fieldFileName).Data) - filePath := cc.Server.Config.FileRoot + ReadFilePath(t.GetField(fieldFilePath).Data) - spew.Dump(cc.Server.Config.FileRoot) - - ffo, err := NewFlattenedFileObject(filePath, fileName) - if err != nil { - return res, err - } - - res = append(res, cc.NewReply(t, - NewField(fieldFileName, []byte(fileName)), - NewField(fieldFileTypeString, ffo.FlatFileInformationFork.TypeSignature), - NewField(fieldFileCreatorString, ffo.FlatFileInformationFork.CreatorSignature), - NewField(fieldFileComment, ffo.FlatFileInformationFork.Comment), - NewField(fieldFileType, ffo.FlatFileInformationFork.TypeSignature), - NewField(fieldFileCreateDate, ffo.FlatFileInformationFork.CreateDate), - NewField(fieldFileModifyDate, ffo.FlatFileInformationFork.ModifyDate), - NewField(fieldFileSize, ffo.FlatFileDataForkHeader.DataSize), - )) - return res, err -} - -// HandleSetFileInfo updates a file or folder name and/or comment from the Get Info window -// TODO: Implement support for comments -// Fields used in the request: -// * 201 File name -// * 202 File path Optional -// * 211 File new name Optional -// * 210 File comment Optional -// Fields used in the reply: None -func HandleSetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - fileName := string(t.GetField(fieldFileName).Data) - filePath := cc.Server.Config.FileRoot + ReadFilePath(t.GetField(fieldFilePath).Data) - //fileComment := t.GetField(fieldFileComment).Data - fileNewName := t.GetField(fieldFileNewName).Data - - if fileNewName != nil { - path := filePath + "/" + fileName - fi, err := os.Stat(path) - if err != nil { - return res, err - } - switch mode := fi.Mode(); { - case mode.IsDir(): - if !authorize(cc.Account.Access, accessRenameFolder) { - res = append(res, cc.NewErrReply(t, "You are not allowed to rename folders.")) - return res, err - } - case mode.IsRegular(): - if !authorize(cc.Account.Access, accessRenameFile) { - res = append(res, cc.NewErrReply(t, "You are not allowed to rename files.")) - return res, err - } - } - - err = os.Rename(filePath+"/"+fileName, filePath+"/"+string(fileNewName)) - if os.IsNotExist(err) { - res = append(res, cc.NewErrReply(t, "Cannot rename file "+fileName+" because it does not exist or cannot be found.")) - return res, err - } - } - - res = append(res, cc.NewReply(t)) - return res, err -} - -// HandleDeleteFile deletes a file or folder -// Fields used in the request: -// * 201 File name -// * 202 File path -// Fields used in the reply: none -func HandleDeleteFile(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - fileName := string(t.GetField(fieldFileName).Data) - filePath := cc.Server.Config.FileRoot + ReadFilePath(t.GetField(fieldFilePath).Data) - - path := "./" + filePath + "/" + fileName - - cc.Server.Logger.Debugw("Delete file", "src", filePath+"/"+fileName) - - fi, err := os.Stat(path) - if err != nil { - res = append(res, cc.NewErrReply(t, "Cannot delete file "+fileName+" because it does not exist or cannot be found.")) - return res, nil - } - switch mode := fi.Mode(); { - case mode.IsDir(): - if !authorize(cc.Account.Access, accessDeleteFolder) { - res = append(res, cc.NewErrReply(t, "You are not allowed to delete folders.")) - return res, err - } - case mode.IsRegular(): - if !authorize(cc.Account.Access, accessDeleteFile) { - res = append(res, cc.NewErrReply(t, "You are not allowed to delete files.")) - return res, err - } - } - - if err := os.RemoveAll(path); err != nil { - return res, err - } - - res = append(res, cc.NewReply(t)) - return res, err -} - -// HandleMoveFile moves files or folders. Note: seemingly not documented -func HandleMoveFile(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - fileName := string(t.GetField(fieldFileName).Data) - filePath := "./" + cc.Server.Config.FileRoot + ReadFilePath(t.GetField(fieldFilePath).Data) - fileNewPath := "./" + cc.Server.Config.FileRoot + ReadFilePath(t.GetField(fieldFileNewPath).Data) - - cc.Server.Logger.Debugw("Move file", "src", filePath+"/"+fileName, "dst", fileNewPath+"/"+fileName) - - path := filePath + "/" + fileName - fi, err := os.Stat(path) - if err != nil { - return res, err - } - switch mode := fi.Mode(); { - case mode.IsDir(): - if !authorize(cc.Account.Access, accessMoveFolder) { - res = append(res, cc.NewErrReply(t, "You are not allowed to move folders.")) - return res, err - } - case mode.IsRegular(): - if !authorize(cc.Account.Access, accessMoveFile) { - res = append(res, cc.NewErrReply(t, "You are not allowed to move files.")) - return res, err - } - } - - err = os.Rename(filePath+"/"+fileName, fileNewPath+"/"+fileName) - if os.IsNotExist(err) { - res = append(res, cc.NewErrReply(t, "Cannot delete file "+fileName+" because it does not exist or cannot be found.")) - return res, err - } - if err != nil { - return []Transaction{}, err - } - // TODO: handle other possible errors; e.g. file delete fails due to file permission issue - - res = append(res, cc.NewReply(t)) - return res, err -} - -func HandleNewFolder(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - newFolderPath := cc.Server.Config.FileRoot - - // fieldFilePath is only present for nested paths - if t.GetField(fieldFilePath).Data != nil { - newFp := NewFilePath(t.GetField(fieldFilePath).Data) - newFolderPath += newFp.String() - } - newFolderPath += "/" + string(t.GetField(fieldFileName).Data) - - if err := os.Mkdir(newFolderPath, 0777); err != nil { - // TODO: Send error response to client - return []Transaction{}, err - } - - res = append(res, cc.NewReply(t)) - return res, err -} - -func HandleSetUser(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - login := DecodeUserString(t.GetField(fieldUserLogin).Data) - userName := string(t.GetField(fieldUserName).Data) - - newAccessLvl := t.GetField(fieldUserAccess).Data - - account := cc.Server.Accounts[login] - account.Access = &newAccessLvl - account.Name = userName - - // If the password field is cleared in the Hotline edit user UI, the SetUser transaction does - // not include fieldUserPassword - if t.GetField(fieldUserPassword).Data == nil { - account.Password = hashAndSalt([]byte("")) - } - if len(t.GetField(fieldUserPassword).Data) > 1 { - account.Password = hashAndSalt(t.GetField(fieldUserPassword).Data) - } - - file := cc.Server.ConfigDir + "Users/" + login + ".yaml" - out, err := yaml.Marshal(&account) - if err != nil { - return res, err - } - if err := ioutil.WriteFile(file, out, 0666); err != nil { - return res, err - } - - // Notify connected clients logged in as the user of the new access level - for _, c := range cc.Server.Clients { - if c.Account.Login == login { - // Note: comment out these two lines to test server-side deny messages - newT := NewTransaction(tranUserAccess, c.ID, NewField(fieldUserAccess, newAccessLvl)) - res = append(res, *newT) - - flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(*c.Flags))) - if authorize(c.Account.Access, accessDisconUser) { - flagBitmap.SetBit(flagBitmap, userFlagAdmin, 1) - } else { - flagBitmap.SetBit(flagBitmap, userFlagAdmin, 0) - } - binary.BigEndian.PutUint16(*c.Flags, uint16(flagBitmap.Int64())) - - c.Account.Access = account.Access - - cc.sendAll( - tranNotifyChangeUser, - NewField(fieldUserID, *c.ID), - NewField(fieldUserFlags, *c.Flags), - NewField(fieldUserName, *c.UserName), - NewField(fieldUserIconID, *c.Icon), - ) - } - } - - // TODO: If we have just promoted a connected user to admin, notify - // connected clients to turn the user red - - res = append(res, cc.NewReply(t)) - return res, err -} - -func HandleGetUser(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - userLogin := string(t.GetField(fieldUserLogin).Data) - decodedUserLogin := NegatedUserString(t.GetField(fieldUserLogin).Data) - account := cc.Server.Accounts[userLogin] - if account == nil { - errorT := cc.NewErrReply(t, "Account does not exist.") - res = append(res, errorT) - return res, err - } - - res = append(res, cc.NewReply(t, - NewField(fieldUserName, []byte(account.Name)), - NewField(fieldUserLogin, []byte(decodedUserLogin)), - NewField(fieldUserPassword, []byte(account.Password)), - NewField(fieldUserAccess, *account.Access), - )) - return res, err -} - -func HandleListUsers(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - var userFields []Field - // TODO: make order deterministic - for _, acc := range cc.Server.Accounts { - userField := acc.Payload() - userFields = append(userFields, NewField(fieldData, userField)) - } - - res = append(res, cc.NewReply(t, userFields...)) - return res, err -} - -// HandleNewUser creates a new user account -func HandleNewUser(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - login := DecodeUserString(t.GetField(fieldUserLogin).Data) - - // If the account already exists, reply with an error - // TODO: make order deterministic - if _, ok := cc.Server.Accounts[login]; ok { - res = append(res, cc.NewErrReply(t, "Cannot create account "+login+" because there is already an account with that login.")) - return res, err - } - - if err := cc.Server.NewUser( - login, - string(t.GetField(fieldUserName).Data), - string(t.GetField(fieldUserPassword).Data), - t.GetField(fieldUserAccess).Data, - ); err != nil { - return []Transaction{}, err - } - - res = append(res, cc.NewReply(t)) - return res, err -} - -func HandleDeleteUser(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - // TODO: Handle case where account doesn't exist; e.g. delete race condition - login := DecodeUserString(t.GetField(fieldUserLogin).Data) - - if err := cc.Server.DeleteUser(login); err != nil { - return res, err - } - - res = append(res, cc.NewReply(t)) - return res, err -} - -// HandleUserBroadcast sends an Administrator Message to all connected clients of the server -func HandleUserBroadcast(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - cc.sendAll( - tranServerMsg, - NewField(fieldData, t.GetField(tranGetMsgs).Data), - NewField(fieldChatOptions, []byte{0}), - ) - - res = append(res, cc.NewReply(t)) - return res, err -} - -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") -} - -func HandleGetClientConnInfoText(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - clientID, _ := byteToInt(t.GetField(fieldUserID).Data) - - clientConn := cc.Server.Clients[uint16(clientID)] - if clientConn == nil { - return res, errors.New("invalid client") - } - - // TODO: Implement non-hardcoded values - template := `Nickname: %s -Name: %s -Account: %s -Address: %s - --------- File Downloads --------- - -%s - -------- Folder Downloads -------- - -None. - ---------- File Uploads ---------- - -None. - --------- Folder Uploads --------- - -None. - -------- Waiting Downloads ------- - -None. - - ` - - activeDownloads := clientConn.Transfers[FileDownload] - activeDownloadList := "None." - for _, dl := range activeDownloads { - activeDownloadList += dl.String() + "\n" - } - - template = fmt.Sprintf( - template, - *clientConn.UserName, - clientConn.Account.Name, - clientConn.Account.Login, - clientConn.Connection.RemoteAddr().String(), - activeDownloadList, - ) - template = strings.Replace(template, "\n", "\r", -1) - - res = append(res, cc.NewReply(t, - NewField(fieldData, []byte(template)), - NewField(fieldUserName, *clientConn.UserName), - )) - return res, err -} - -func HandleGetUserNameList(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - res = append(res, cc.NewReply(t, cc.Server.connectedUsers()...)) - - return res, err -} - -func (cc *ClientConn) notifyNewUserHasJoined() (res []Transaction, err error) { - // Notify other ccs that a new user has connected - cc.NotifyOthers( - *NewTransaction( - tranNotifyChangeUser, nil, - NewField(fieldUserName, *cc.UserName), - NewField(fieldUserID, *cc.ID), - NewField(fieldUserIconID, *cc.Icon), - NewField(fieldUserFlags, *cc.Flags), - ), - ) - - return res, nil -} - -func HandleTranAgreed(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - bs := make([]byte, 2) - binary.BigEndian.PutUint16(bs, *cc.Server.NextGuestID) - - *cc.UserName = t.GetField(fieldUserName).Data - *cc.ID = bs - *cc.Icon = t.GetField(fieldUserIconID).Data - - options := t.GetField(fieldOptions).Data - optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options))) - - flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(*cc.Flags))) - - // Check refuse private PM option - if optBitmap.Bit(refusePM) == 1 { - flagBitmap.SetBit(flagBitmap, userFlagRefusePM, 1) - binary.BigEndian.PutUint16(*cc.Flags, uint16(flagBitmap.Int64())) - } - - // Check refuse private chat option - if optBitmap.Bit(refuseChat) == 1 { - flagBitmap.SetBit(flagBitmap, userFLagRefusePChat, 1) - binary.BigEndian.PutUint16(*cc.Flags, uint16(flagBitmap.Int64())) - } - - // Check auto response - if optBitmap.Bit(autoResponse) == 1 { - *cc.AutoReply = t.GetField(fieldAutomaticResponse).Data - } else { - *cc.AutoReply = []byte{} - } - - _, _ = cc.notifyNewUserHasJoined() - - res = append(res, cc.NewReply(t)) - - return res, err -} - -const defaultNewsDateFormat = "Jan02 15:04" // Jun23 20:49 -// "Mon, 02 Jan 2006 15:04:05 MST" - -const defaultNewsTemplate = `From %s (%s): - -%s - -__________________________________________________________` - -// HandleTranOldPostNews updates the flat news -// Fields used in this request: -// 101 Data -func HandleTranOldPostNews(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - cc.Server.flatNewsMux.Lock() - defer cc.Server.flatNewsMux.Unlock() - - newsDateTemplate := defaultNewsDateFormat - if cc.Server.Config.NewsDateFormat != "" { - newsDateTemplate = cc.Server.Config.NewsDateFormat - } - - newsTemplate := defaultNewsTemplate - if cc.Server.Config.NewsDelimiter != "" { - newsTemplate = cc.Server.Config.NewsDelimiter - } - - newsPost := fmt.Sprintf(newsTemplate+"\r", *cc.UserName, time.Now().Format(newsDateTemplate), t.GetField(fieldData).Data) - newsPost = strings.Replace(newsPost, "\n", "\r", -1) - - // update news in memory - cc.Server.FlatNews = append([]byte(newsPost), cc.Server.FlatNews...) - - // update news on disk - if err := ioutil.WriteFile(cc.Server.ConfigDir+"MessageBoard.txt", cc.Server.FlatNews, 0644); err != nil { - return res, err - } - - // Notify all clients of updated news - cc.sendAll( - tranNewMsg, - NewField(fieldData, []byte(newsPost)), - ) - - res = append(res, cc.NewReply(t)) - return res, err -} - -func HandleDisconnectUser(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - clientConn := cc.Server.Clients[binary.BigEndian.Uint16(t.GetField(fieldUserID).Data)] - - if authorize(clientConn.Account.Access, accessCannotBeDiscon) { - res = append(res, cc.NewErrReply(t, clientConn.Account.Login+" is not allowed to be disconnected.")) - return res, err - } - - if err := clientConn.Connection.Close(); err != nil { - return res, err - } - - res = append(res, cc.NewReply(t)) - return res, err -} - -func HandleGetNewsCatNameList(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - // Fields used in the request: - // 325 News path (Optional) - - newsPath := t.GetField(fieldNewsPath).Data - cc.Server.Logger.Infow("NewsPath: ", "np", string(newsPath)) - - pathStrs := ReadNewsPath(t.GetField(fieldNewsPath).Data) - cats := cc.Server.GetNewsCatByPath(pathStrs) - - // 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] - fieldData = append(fieldData, NewField( - fieldNewsCatListData15, - cat.Payload(), - )) - } - - res = append(res, cc.NewReply(t, fieldData...)) - return res, err -} - -func HandleNewNewsCat(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - name := string(t.GetField(fieldNewsCatName).Data) - pathStrs := ReadNewsPath(t.GetField(fieldNewsPath).Data) - - cats := cc.Server.GetNewsCatByPath(pathStrs) - cats[name] = NewsCategoryListData15{ - Name: name, - Type: []byte{0, 3}, - Articles: map[uint32]*NewsArtData{}, - SubCats: make(map[string]NewsCategoryListData15), - } - - if err := cc.Server.writeThreadedNews(); err != nil { - return res, err - } - res = append(res, cc.NewReply(t)) - return res, err -} - -func HandleNewNewsFldr(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - // Fields used in the request: - // 322 News category name - // 325 News path - name := string(t.GetField(fieldFileName).Data) - pathStrs := ReadNewsPath(t.GetField(fieldNewsPath).Data) - - cc.Server.Logger.Infof("Creating new news folder %s", name) - - cats := cc.Server.GetNewsCatByPath(pathStrs) - cats[name] = NewsCategoryListData15{ - Name: name, - Type: []byte{0, 2}, - Articles: map[uint32]*NewsArtData{}, - SubCats: make(map[string]NewsCategoryListData15), - } - if err := cc.Server.writeThreadedNews(); err != nil { - return res, err - } - res = append(res, cc.NewReply(t)) - return res, err -} - -// Fields used in the request: -// 325 News path Optional -// -// Reply fields: -// 321 News article list data Optional -func HandleGetNewsArtNameList(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - pathStrs := ReadNewsPath(t.GetField(fieldNewsPath).Data) - - var cat NewsCategoryListData15 - cats := cc.Server.ThreadedNews.Categories - - for _, path := range pathStrs { - cat = cats[path] - cats = cats[path].SubCats - } - - nald := cat.GetNewsArtListData() - - res = append(res, cc.NewReply(t, NewField(fieldNewsArtListData, nald.Payload()))) - return res, err -} - -func HandleGetNewsArtData(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - // Request fields - // 325 News path - // 326 News article ID - // 327 News article data flavor - - pathStrs := ReadNewsPath(t.GetField(fieldNewsPath).Data) - - var cat NewsCategoryListData15 - cats := cc.Server.ThreadedNews.Categories - - for _, path := range pathStrs { - cat = cats[path] - cats = cats[path].SubCats - } - newsArtID := t.GetField(fieldNewsArtID).Data - - convertedArtID := binary.BigEndian.Uint16(newsArtID) - - art := cat.Articles[uint32(convertedArtID)] - if art == nil { - res = append(res, cc.NewReply(t)) - return res, err - } - - // Reply fields - // 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 - // 327 News article data flavor "Should be “text/plain” - // 333 News article data Optional (if data flavor is “text/plain”) - - res = append(res, cc.NewReply(t, - NewField(fieldNewsArtTitle, []byte(art.Title)), - NewField(fieldNewsArtPoster, []byte(art.Poster)), - NewField(fieldNewsArtDate, art.Date), - NewField(fieldNewsArtPrevArt, art.PrevArt), - NewField(fieldNewsArtNextArt, art.NextArt), - NewField(fieldNewsArtParentArt, art.ParentArt), - NewField(fieldNewsArt1stChildArt, art.FirstChildArt), - NewField(fieldNewsArtDataFlav, []byte("text/plain")), - NewField(fieldNewsArtData, []byte(art.Data)), - )) - return res, err -} - -func HandleDelNewsItem(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - // Access: News Delete Folder (37) or News Delete Category (35) - - pathStrs := ReadNewsPath(t.GetField(fieldNewsPath).Data) - - // TODO: determine if path is a Folder (Bundle) or Category and check for permission - - cc.Server.Logger.Infof("DelNewsItem %v", pathStrs) - - cats := cc.Server.ThreadedNews.Categories - - delName := pathStrs[len(pathStrs)-1] - if len(pathStrs) > 1 { - for _, path := range pathStrs[0 : len(pathStrs)-1] { - cats = cats[path].SubCats - } - } - - delete(cats, delName) - - err = cc.Server.writeThreadedNews() - if err != nil { - return res, err - } - - // Reply params: none - res = append(res, cc.NewReply(t)) - - return res, err -} - -func HandleDelNewsArt(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - // 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 := binary.BigEndian.Uint16(t.GetField(fieldNewsArtID).Data) - - // TODO: Delete recursive - cats := cc.Server.GetNewsCatByPath(pathStrs[:len(pathStrs)-1]) - - catName := pathStrs[len(pathStrs)-1] - cat := cats[catName] - - delete(cat.Articles, uint32(ID)) - - cats[catName] = cat - if err := cc.Server.writeThreadedNews(); err != nil { - return res, err - } - - res = append(res, cc.NewReply(t)) - return res, err -} - -func HandlePostNewsArt(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - // Request fields - // 325 News path - // 326 News article ID ID of the parent article? - // 328 News article title - // 334 News article flags - // 327 News article data flavor Currently “text/plain” - // 333 News article data - - pathStrs := ReadNewsPath(t.GetField(fieldNewsPath).Data) - cats := cc.Server.GetNewsCatByPath(pathStrs[:len(pathStrs)-1]) - - catName := pathStrs[len(pathStrs)-1] - cat := cats[catName] - - newArt := NewsArtData{ - Title: string(t.GetField(fieldNewsArtTitle).Data), - Poster: string(*cc.UserName), - Date: NewsDate(), - PrevArt: []byte{0, 0, 0, 0}, - NextArt: []byte{0, 0, 0, 0}, - ParentArt: append([]byte{0, 0}, t.GetField(fieldNewsArtID).Data...), - FirstChildArt: []byte{0, 0, 0, 0}, - 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 := binary.BigEndian.Uint16(t.GetField(fieldNewsArtID).Data) - if parentID != 0 { - parentArt := cat.Articles[uint32(parentID)] - - if bytes.Equal(parentArt.FirstChildArt, []byte{0, 0, 0, 0}) { - binary.BigEndian.PutUint32(parentArt.FirstChildArt, nextID) - } - } - - cat.Articles[nextID] = &newArt - - cats[catName] = cat - if err := cc.Server.writeThreadedNews(); err != nil { - return res, err - } - - res = append(res, cc.NewReply(t)) - return res, err -} - -// HandleGetMsgs returns the flat news data -func HandleGetMsgs(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - res = append(res, cc.NewReply(t, NewField(fieldData, cc.Server.FlatNews))) - - return res, err -} - -func HandleDownloadFile(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - fileName := t.GetField(fieldFileName).Data - filePath := ReadFilePath(t.GetField(fieldFilePath).Data) - - ffo, err := NewFlattenedFileObject(cc.Server.Config.FileRoot+filePath, string(fileName)) - if err != nil { - return res, err - } - - transactionRef := cc.Server.NewTransactionRef() - data := binary.BigEndian.Uint32(transactionRef) - - cc.Server.Logger.Infow("File download", "path", filePath) - - ft := &FileTransfer{ - FileName: fileName, - FilePath: []byte(filePath), - ReferenceNumber: transactionRef, - Type: FileDownload, - } - - cc.Server.FileTransfers[data] = ft - cc.Transfers[FileDownload] = append(cc.Transfers[FileDownload], ft) - - res = append(res, cc.NewReply(t, - NewField(fieldRefNum, transactionRef), - NewField(fieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count - NewField(fieldTransferSize, ffo.TransferSize()), - NewField(fieldFileSize, ffo.FlatFileDataForkHeader.DataSize), - )) - - return res, err -} - -// Download all files from the specified folder and sub-folders -// response example -// -// 00 -// 01 -// 00 00 -// 00 00 00 11 -// 00 00 00 00 -// 00 00 00 18 -// 00 00 00 18 -// -// 00 03 -// -// 00 6c // transfer size -// 00 04 // len -// 00 0f d5 ae -// -// 00 dc // field Folder item count -// 00 02 // len -// 00 02 -// -// 00 6b // ref number -// 00 04 // len -// 00 03 64 b1 -func HandleDownloadFolder(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - transactionRef := cc.Server.NewTransactionRef() - data := binary.BigEndian.Uint32(transactionRef) - - fileTransfer := &FileTransfer{ - FileName: t.GetField(fieldFileName).Data, - FilePath: t.GetField(fieldFilePath).Data, - ReferenceNumber: transactionRef, - Type: FolderDownload, - } - cc.Server.FileTransfers[data] = fileTransfer - cc.Transfers[FolderDownload] = append(cc.Transfers[FolderDownload], fileTransfer) - - fp := NewFilePath(t.GetField(fieldFilePath).Data) - - fullFilePath := fmt.Sprintf("./%v/%v", cc.Server.Config.FileRoot+fp.String(), string(fileTransfer.FileName)) - transferSize, err := CalcTotalSize(fullFilePath) - if err != nil { - return res, err - } - itemCount, err := CalcItemCount(fullFilePath) - if err != nil { - return res, err - } - res = append(res, cc.NewReply(t, - NewField(fieldRefNum, transactionRef), - NewField(fieldTransferSize, transferSize), - NewField(fieldFolderItemCount, itemCount), - NewField(fieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count - )) - return res, err -} - -// Upload all files from the local folder and its subfolders to the specified path on the server -// Fields used in the request -// 201 File name -// 202 File path -// 108 Transfer size Total size of all items in the folder -// 220 Folder item count -// 204 File transfer options "Optional Currently set to 1" (TODO: ??) -func HandleUploadFolder(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - transactionRef := cc.Server.NewTransactionRef() - data := binary.BigEndian.Uint32(transactionRef) - - fileTransfer := &FileTransfer{ - FileName: t.GetField(fieldFileName).Data, - FilePath: t.GetField(fieldFilePath).Data, - ReferenceNumber: transactionRef, - Type: FolderUpload, - FolderItemCount: t.GetField(fieldFolderItemCount).Data, - TransferSize: t.GetField(fieldTransferSize).Data, - } - cc.Server.FileTransfers[data] = fileTransfer - - res = append(res, cc.NewReply(t, NewField(fieldRefNum, transactionRef))) - return res, err -} - -func HandleUploadFile(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - fileName := t.GetField(fieldFileName).Data - filePath := t.GetField(fieldFilePath).Data - - transactionRef := cc.Server.NewTransactionRef() - data := binary.BigEndian.Uint32(transactionRef) - - fileTransfer := &FileTransfer{ - FileName: fileName, - FilePath: filePath, - ReferenceNumber: transactionRef, - Type: FileUpload, - } - - cc.Server.FileTransfers[data] = fileTransfer - - res = append(res, cc.NewReply(t, NewField(fieldRefNum, transactionRef))) - return res, err -} - -// User options -const ( - refusePM = 0 - refuseChat = 1 - autoResponse = 2 -) - -func HandleSetClientUserInfo(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - var icon []byte - if len(t.GetField(fieldUserIconID).Data) == 4 { - icon = t.GetField(fieldUserIconID).Data[2:] - } else { - icon = t.GetField(fieldUserIconID).Data - } - *cc.Icon = icon - *cc.UserName = t.GetField(fieldUserName).Data - - // the options field is only passed by the client versions > 1.2.3. - options := t.GetField(fieldOptions).Data - - if options != nil { - optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options))) - flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(*cc.Flags))) - - // Check refuse private PM option - if optBitmap.Bit(refusePM) == 1 { - flagBitmap.SetBit(flagBitmap, userFlagRefusePM, 1) - binary.BigEndian.PutUint16(*cc.Flags, uint16(flagBitmap.Int64())) - } - - // Check refuse private chat option - if optBitmap.Bit(refuseChat) == 1 { - flagBitmap.SetBit(flagBitmap, userFLagRefusePChat, 1) - binary.BigEndian.PutUint16(*cc.Flags, uint16(flagBitmap.Int64())) - } - - // Check auto response - if optBitmap.Bit(autoResponse) == 1 { - *cc.AutoReply = t.GetField(fieldAutomaticResponse).Data - } else { - *cc.AutoReply = []byte{} - } - } - - // Notify all clients of updated user info - cc.sendAll( - tranNotifyChangeUser, - NewField(fieldUserID, *cc.ID), - NewField(fieldUserIconID, *cc.Icon), - NewField(fieldUserFlags, *cc.Flags), - NewField(fieldUserName, *cc.UserName), - ) - - return res, err -} - -// HandleKeepAlive response to keepalive transactions with an empty reply -// HL 1.9.2 Client sends keepalive msg every 3 minutes -// HL 1.2.3 Client doesn't send keepalives -func HandleKeepAlive(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - res = append(res, cc.NewReply(t)) - - return res, err -} - -func HandleGetFileNameList(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - filePath := cc.Server.Config.FileRoot - - path := t.GetField(fieldFilePath).Data - if len(path) > 0 { - filePath = cc.Server.Config.FileRoot + ReadFilePath(path) - } - - fileNames, err := getFileNameList(filePath) - if err != nil { - return res, err - } - - res = append(res, cc.NewReply(t, fileNames...)) - - return res, err -} - -// ================================= -// Hotline private chat flow -// ================================= -// 1. ClientA sends tranInviteNewChat to server with user ID to invite -// 2. Server creates new ChatID -// 3. Server sends tranInviteToChat to invitee -// 4. Server replies to ClientA with new Chat ID -// -// A dialog box pops up in the invitee client with options to accept or decline the invitation. -// If Accepted is clicked: -// 1. ClientB sends tranJoinChat with fieldChatID - -// HandleInviteNewChat invites users to new private chat -func HandleInviteNewChat(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - // Client to Invite - targetID := t.GetField(fieldUserID).Data - newChatID := cc.Server.NewPrivateChat(cc) - - res = append(res, - *NewTransaction( - tranInviteToChat, - &targetID, - NewField(fieldChatID, newChatID), - NewField(fieldUserName, *cc.UserName), - NewField(fieldUserID, *cc.ID), - ), - ) - - res = append(res, - cc.NewReply(t, - NewField(fieldChatID, newChatID), - NewField(fieldUserName, *cc.UserName), - NewField(fieldUserID, *cc.ID), - NewField(fieldUserIconID, *cc.Icon), - NewField(fieldUserFlags, *cc.Flags), - ), - ) - - return res, err -} - -func HandleInviteToChat(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - // Client to Invite - targetID := t.GetField(fieldUserID).Data - chatID := t.GetField(fieldChatID).Data - - res = append(res, - *NewTransaction( - tranInviteToChat, - &targetID, - NewField(fieldChatID, chatID), - NewField(fieldUserName, *cc.UserName), - NewField(fieldUserID, *cc.ID), - ), - ) - res = append(res, - cc.NewReply( - t, - NewField(fieldChatID, chatID), - NewField(fieldUserName, *cc.UserName), - NewField(fieldUserID, *cc.ID), - NewField(fieldUserIconID, *cc.Icon), - NewField(fieldUserFlags, *cc.Flags), - ), - ) - - return res, err -} - -func HandleRejectChatInvite(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - chatID := t.GetField(fieldChatID).Data - chatInt := binary.BigEndian.Uint32(chatID) - - privChat := cc.Server.PrivateChats[chatInt] - - resMsg := append(*cc.UserName, []byte(" declined invitation to chat")...) - - for _, c := range sortedClients(privChat.ClientConn) { - res = append(res, - *NewTransaction( - tranChatMsg, - c.ID, - NewField(fieldChatID, chatID), - NewField(fieldData, resMsg), - ), - ) - } - - return res, err -} - -// HandleJoinChat is sent from a v1.8+ Hotline client when the joins a private chat -// Fields used in the reply: -// * 115 Chat subject -// * 300 User name with info (Optional) -// * 300 (more user names with info) -func HandleJoinChat(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - chatID := t.GetField(fieldChatID).Data - chatInt := binary.BigEndian.Uint32(chatID) - - privChat := cc.Server.PrivateChats[chatInt] - - // Send tranNotifyChatChangeUser to current members of the chat to inform of new user - for _, c := range sortedClients(privChat.ClientConn) { - res = append(res, - *NewTransaction( - tranNotifyChatChangeUser, - c.ID, - NewField(fieldChatID, chatID), - NewField(fieldUserName, *cc.UserName), - NewField(fieldUserID, *cc.ID), - NewField(fieldUserIconID, *cc.Icon), - NewField(fieldUserFlags, *cc.Flags), - ), - ) - } - - privChat.ClientConn[cc.uint16ID()] = cc - - replyFields := []Field{NewField(fieldChatSubject, []byte(privChat.Subject))} - for _, c := range sortedClients(privChat.ClientConn) { - user := User{ - ID: *c.ID, - Icon: *c.Icon, - Flags: *c.Flags, - Name: string(*c.UserName), - } - - replyFields = append(replyFields, NewField(fieldUsernameWithInfo, user.Payload())) - } - - res = append(res, cc.NewReply(t, replyFields...)) - return res, err -} - -// HandleLeaveChat is sent from a v1.8+ Hotline client when the user exits a private chat -// Fields used in the request: -// * 114 fieldChatID -// Reply is not expected. -func HandleLeaveChat(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - chatID := t.GetField(fieldChatID).Data - chatInt := binary.BigEndian.Uint32(chatID) - - privChat := cc.Server.PrivateChats[chatInt] - - delete(privChat.ClientConn, cc.uint16ID()) - - // Notify members of the private chat that the user has left - for _, c := range sortedClients(privChat.ClientConn) { - res = append(res, - *NewTransaction( - tranNotifyChatDeleteUser, - c.ID, - NewField(fieldChatID, chatID), - NewField(fieldUserID, *cc.ID), - ), - ) - } - - return res, err -} - -// 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 -// * 115 Chat subject Chat subject string -// Reply is not expected. -func HandleSetChatSubject(cc *ClientConn, t *Transaction) (res []Transaction, err error) { - chatID := t.GetField(fieldChatID).Data - chatInt := binary.BigEndian.Uint32(chatID) - - privChat := cc.Server.PrivateChats[chatInt] - privChat.Subject = string(t.GetField(fieldChatSubject).Data) - - for _, c := range sortedClients(privChat.ClientConn) { - res = append(res, - *NewTransaction( - tranNotifyChatSubject, - c.ID, - NewField(fieldChatID, chatID), - NewField(fieldChatSubject, t.GetField(fieldChatSubject).Data), - ), - ) - } - - return res, err -} diff --git a/transaction_handlers_test.go b/transaction_handlers_test.go deleted file mode 100644 index 3042da2..0000000 --- a/transaction_handlers_test.go +++ /dev/null @@ -1,394 +0,0 @@ -package hotline - -import ( - "github.com/stretchr/testify/assert" - "math/rand" - "reflect" - "testing" -) - -func TestHandleSetChatSubject(t *testing.T) { - type args struct { - cc *ClientConn - t *Transaction - } - tests := []struct { - name string - args args - want []Transaction - wantErr bool - }{ - { - name: "sends chat subject to private chat members", - args: args{ - cc: &ClientConn{ - UserName: &[]byte{0x00, 0x01}, - Server: &Server{ - PrivateChats: map[uint32]*PrivateChat{ - uint32(1): { - Subject: "unset", - ClientConn: map[uint16]*ClientConn{ - uint16(1): { - Account: &Account{ - Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: &[]byte{0, 1}, - }, - uint16(2): { - Account: &Account{ - Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: &[]byte{0, 2}, - }, - }, - }, - }, - Clients: map[uint16]*ClientConn{ - uint16(1): { - Account: &Account{ - Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: &[]byte{0, 1}, - }, - uint16(2): { - Account: &Account{ - Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: &[]byte{0, 2}, - }, - }, - }, - }, - t: &Transaction{ - Flags: 0x00, - IsReply: 0x00, - Type: []byte{0, 0x6a}, - ID: []byte{0, 0, 0, 1}, - ErrorCode: []byte{0, 0, 0, 0}, - Fields: []Field{ - NewField(fieldChatID, []byte{0, 0, 0, 1}), - NewField(fieldChatSubject, []byte("Test Subject")), - }, - }, - }, - want: []Transaction{ - { - clientID: &[]byte{0, 1}, - Flags: 0x00, - IsReply: 0x00, - Type: []byte{0, 0x77}, - ID: []byte{0x9a, 0xcb, 0x04, 0x42}, // Random ID from rand.Seed(1) - ErrorCode: []byte{0, 0, 0, 0}, - Fields: []Field{ - NewField(fieldChatID, []byte{0, 0, 0, 1}), - NewField(fieldChatSubject, []byte("Test Subject")), - }, - }, - { - clientID: &[]byte{0, 2}, - Flags: 0x00, - IsReply: 0x00, - Type: []byte{0, 0x77}, - ID: []byte{0xf0, 0xc5, 0x34, 0x1e}, // Random ID from rand.Seed(1) - ErrorCode: []byte{0, 0, 0, 0}, - Fields: []Field{ - NewField(fieldChatID, []byte{0, 0, 0, 1}), - NewField(fieldChatSubject, []byte("Test Subject")), - }, - }, - }, - wantErr: false, - }, - } - for _, tt := range tests { - rand.Seed(1) // reset seed between tests to make transaction IDs predictable - - t.Run(tt.name, func(t *testing.T) { - got, err := HandleSetChatSubject(tt.args.cc, tt.args.t) - if (err != nil) != tt.wantErr { - t.Errorf("HandleSetChatSubject() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !assert.Equal(t, tt.want, got) { - t.Errorf("HandleSetChatSubject() got = %v, want %v", got, tt.want) - } - }) - } -} - -func TestHandleLeaveChat(t *testing.T) { - type args struct { - cc *ClientConn - t *Transaction - } - tests := []struct { - name string - args args - want []Transaction - wantErr bool - }{ - { - name: "returns expected transactions", - args: args{ - cc: &ClientConn{ - ID: &[]byte{0, 2}, - Server: &Server{ - PrivateChats: map[uint32]*PrivateChat{ - uint32(1): { - ClientConn: map[uint16]*ClientConn{ - uint16(1): { - Account: &Account{ - Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: &[]byte{0, 1}, - }, - uint16(2): { - Account: &Account{ - Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: &[]byte{0, 2}, - }, - }, - }, - }, - Clients: map[uint16]*ClientConn{ - uint16(1): { - Account: &Account{ - Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: &[]byte{0, 1}, - }, - uint16(2): { - Account: &Account{ - Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: &[]byte{0, 2}, - }, - }, - }, - }, - t: NewTransaction(tranDeleteUser,nil, NewField(fieldChatID, []byte{0, 0, 0, 1})), - }, - want: []Transaction{ - { - clientID: &[]byte{0, 1}, - Flags: 0x00, - IsReply: 0x00, - Type: []byte{0, 0x76}, - ID: []byte{0x9a, 0xcb, 0x04, 0x42}, // Random ID from rand.Seed(1) - ErrorCode: []byte{0, 0, 0, 0}, - Fields: []Field{ - NewField(fieldChatID, []byte{0, 0, 0, 1}), - NewField(fieldUserID, []byte{0, 2}), - }, - }, - }, - wantErr: false, - }, - } - for _, tt := range tests { - rand.Seed(1) - t.Run(tt.name, func(t *testing.T) { - got, err := HandleLeaveChat(tt.args.cc, tt.args.t) - if (err != nil) != tt.wantErr { - t.Errorf("HandleLeaveChat() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !assert.Equal(t, tt.want, got) { - t.Errorf("HandleLeaveChat() got = %v, want %v", got, tt.want) - } - }) - } -} - - -func TestHandleGetUserNameList(t *testing.T) { - type args struct { - cc *ClientConn - t *Transaction - } - tests := []struct { - name string - args args - want []Transaction - wantErr bool - }{ - { - name: "replies with userlist transaction", - args: args{ - cc: &ClientConn{ - - ID: &[]byte{1, 1}, - Server: &Server{ - Clients: map[uint16]*ClientConn{ - uint16(1): { - ID: &[]byte{0, 1}, - Icon: &[]byte{0, 2}, - Flags: &[]byte{0, 3}, - UserName: &[]byte{0, 4}, - }, - }, - }, - }, - t: &Transaction{ - ID: []byte{0, 0, 0, 1}, - Type: []byte{0, 1}, - }, - }, - want: []Transaction{ - { - clientID: &[]byte{1, 1}, - Flags: 0x00, - IsReply: 0x01, - Type: []byte{0, 1}, - ID: []byte{0, 0, 0, 1}, - ErrorCode: []byte{0, 0, 0, 0}, - Fields: []Field{ - NewField( - fieldUsernameWithInfo, - []byte{00, 01, 00, 02, 00, 03, 00, 02, 00, 04}, - ), - }, - }, - }, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := HandleGetUserNameList(tt.args.cc, tt.args.t) - if (err != nil) != tt.wantErr { - t.Errorf("HandleGetUserNameList() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("HandleGetUserNameList() got = %v, want %v", got, tt.want) - } - }) - } -} - -func TestHandleChatSend(t *testing.T) { - type args struct { - cc *ClientConn - t *Transaction - } - tests := []struct { - name string - args args - want []Transaction - wantErr bool - }{ - { - name: "sends chat msg transaction to all clients", - args: args{ - cc: &ClientConn{ - UserName: &[]byte{0x00, 0x01}, - Server: &Server{ - Clients: map[uint16]*ClientConn{ - uint16(1): { - Account: &Account{ - Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: &[]byte{0, 1}, - }, - uint16(2): { - Account: &Account{ - Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: &[]byte{0, 2}, - }, - }, - }, - }, - t: &Transaction{ - Fields: []Field{ - NewField(fieldData, []byte("hai")), - }, - }, - }, - want: []Transaction{ - { - clientID: &[]byte{0, 1}, - Flags: 0x00, - IsReply: 0x00, - Type: []byte{0, 0x6a}, - ID: []byte{0x9a, 0xcb, 0x04, 0x42}, // Random ID from rand.Seed(1) - ErrorCode: []byte{0, 0, 0, 0}, - Fields: []Field{ - NewField(fieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), - }, - }, - { - clientID: &[]byte{0, 2}, - Flags: 0x00, - IsReply: 0x00, - Type: []byte{0, 0x6a}, - ID: []byte{0xf0, 0xc5, 0x34, 0x1e}, // Random ID from rand.Seed(1) - ErrorCode: []byte{0, 0, 0, 0}, - Fields: []Field{ - NewField(fieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), - }, - }, - }, - wantErr: false, - }, - { - name: "only sends chat msg to clients with accessReadChat permission", - args: args{ - cc: &ClientConn{ - UserName: &[]byte{0x00, 0x01}, - Server: &Server{ - Clients: map[uint16]*ClientConn{ - uint16(1): { - Account: &Account{ - Access: &[]byte{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: &[]byte{0, 1}, - }, - uint16(2): { - Account: &Account{ - Access: &[]byte{0, 0, 0, 0, 0, 0, 0, 0}, - }, - ID: &[]byte{0, 2}, - }, - }, - }, - }, - t: &Transaction{ - Fields: []Field{ - NewField(fieldData, []byte("hai")), - }, - }, - }, - want: []Transaction{ - { - clientID: &[]byte{0, 1}, - Flags: 0x00, - IsReply: 0x00, - Type: []byte{0, 0x6a}, - ID: []byte{0x9a, 0xcb, 0x04, 0x42}, // Random ID from rand.Seed(1) - ErrorCode: []byte{0, 0, 0, 0}, - Fields: []Field{ - NewField(fieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), - }, - }, - }, - wantErr: false, - }, - } - for _, tt := range tests { - rand.Seed(1) // reset seed between tests to make transaction IDs predictable - t.Run(tt.name, func(t *testing.T) { - got, err := HandleChatSend(tt.args.cc, tt.args.t) - - if (err != nil) != tt.wantErr { - t.Errorf("HandleChatSend() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !assert.Equal(t, tt.want, got) { - t.Errorf("HandleChatSend() got = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/transaction_test.go b/transaction_test.go deleted file mode 100644 index 227b9e0..0000000 --- a/transaction_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package hotline - -import ( - "github.com/stretchr/testify/assert" - "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: []byte{0x00, 0x65}, - FieldSize: []byte{0x00, 0x04}, - Data: []byte{0x01, 0x02, 0x03, 0x04}, - }, - { - ID: []byte{0x00, 0x66}, - FieldSize: []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 TestReadTransaction(t *testing.T) { - sampleTransaction := &Transaction{ - Flags: byte(0), - IsReply: byte(0), - Type: []byte{0x000, 0x93}, - ID: []byte{0x000, 0x00, 0x00, 0x01}, - ErrorCode: []byte{0x000, 0x00, 0x00, 0x00}, - TotalSize: []byte{0x000, 0x00, 0x00, 0x08}, - DataSize: []byte{0x000, 0x00, 0x00, 0x08}, - ParamCount: []byte{0x00, 0x01}, - Fields: []Field{ - { - ID: []byte{0x00, 0x01}, - FieldSize: []byte{0x00, 0x02}, - Data: []byte{0xff, 0xff}, - }, - }, - } - - type args struct { - buf []byte - } - tests := []struct { - name string - args args - want *Transaction - want1 int - wantErr bool - }{ - { - name: "when buf contains all bytes for a single transaction", - args: args{ - buf: sampleTransaction.Payload(), - }, - want: sampleTransaction, - want1: len(sampleTransaction.Payload()), - wantErr: false, - }, - { - name: "when len(buf) is less than the length of the transaction", - args: args{ - buf: sampleTransaction.Payload()[:len(sampleTransaction.Payload()) - 1], - }, - want: nil, - want1: 0, - wantErr: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, got1, err := ReadTransaction(tt.args.buf) - if (err != nil) != tt.wantErr { - t.Errorf("ReadTransaction() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !assert.Equal(t, tt.want, got) { - t.Errorf("ReadTransaction() got = %v, want %v", got, tt.want) - } - if got1 != tt.want1 { - t.Errorf("ReadTransaction() got1 = %v, want %v", got1, tt.want1) - } - }) - } -} diff --git a/transfer.go b/transfer.go deleted file mode 100644 index ecbe324..0000000 --- a/transfer.go +++ /dev/null @@ -1,49 +0,0 @@ -package hotline - -import ( - "bytes" - "encoding/binary" - "errors" -) - -type Transfer struct { - Protocol [4]byte // "HTXF" 0x48545846 - ReferenceNumber [4]byte // Unique ID generated for the transfer - DataSize [4]byte // File size - RSVD [4]byte // Not implemented in Hotline Protocol -} - -func NewReadTransfer(b []byte) (Transfer, error) { - r := bytes.NewReader(b) - var transfer Transfer - - if err := binary.Read(r, binary.BigEndian, &transfer); err != nil { - return transfer, err - } - - // 0x48545846 (HTXF) is the only supported transfer protocol - if transfer.Protocol != [4]byte{0x48, 0x54, 0x58, 0x46} { - return transfer, errors.New("invalid protocol") - } - - return transfer, nil -} -// -//type FolderTransfer struct { -// Protocol [4]byte // "HTXF" 0x48545846 -// ReferenceNumber [4]byte // Unique ID generated for the transfer -// DataSize [4]byte // File size -// RSVD [4]byte // Not implemented in Hotline Protocol -// Action [2]byte // Next file action -//} -// -//func ReadFolderTransfer(b []byte) (FolderTransfer, error) { -// r := bytes.NewReader(b) -// var decodedEvent FolderTransfer -// -// if err := binary.Read(r, binary.BigEndian, &decodedEvent); err != nil { -// return decodedEvent, err -// } -// -// return decodedEvent, nil -//} diff --git a/transfer_test.go b/transfer_test.go deleted file mode 100644 index 52044b0..0000000 --- a/transfer_test.go +++ /dev/null @@ -1,7 +0,0 @@ -package hotline - -import "testing" - -func TestReadTransfer(t *testing.T) { - -} diff --git a/user.go b/user.go deleted file mode 100644 index f80fd72..0000000 --- a/user.go +++ /dev/null @@ -1,69 +0,0 @@ -package hotline - -import ( - "encoding/binary" -) - -// User flags are stored as a 2 byte bitmap with the following values: -const ( - userFlagAway = 0 // User is away - userFlagAdmin = 1 // User is admin - userFlagRefusePM = 2 // User refuses private messages - userFLagRefusePChat = 3 // User refuses private chat -) - -type User struct { - ID []byte // Size 2 - Icon []byte // Size 2 - Flags []byte // Size 2 - Name string // Variable length user name -} - -func (u User) Payload() []byte { - nameLen := make([]byte, 2) - binary.BigEndian.PutUint16(nameLen, uint16(len(u.Name))) - - if len(u.Icon) == 4 { - u.Icon = u.Icon[2:] - } - - if len(u.Flags) == 4 { - u.Flags = u.Flags[2:] - } - - out := append(u.ID[:2], u.Icon[:2]...) - out = append(out, u.Flags[:2]...) - out = append(out, nameLen...) - out = append(out, u.Name...) - - return out -} - -func ReadUser(b []byte) (*User, error) { - u := &User{ - ID: b[0:2], - Icon: b[2:4], - Flags: b[4:6], - Name: string(b[8:]), - } - return u, nil -} - -// DecodeUserString decodes an obfuscated user string from a client -// e.g. 98 8a 9a 8c 8b => "guest" -func DecodeUserString(encodedString []byte) (decodedString string) { - for _, char := range encodedString { - decodedString += string(rune(255 - uint(char))) - } - return decodedString -} - -// Take a []byte of uncoded ascii as input and encode it -// TODO: change the method signature to take a string and return []byte -func NegatedUserString(encodedString []byte) string { - var decodedString string - for _, char := range encodedString { - decodedString += string(255 - uint8(char))[1:] - } - return decodedString -} diff --git a/user_test.go b/user_test.go deleted file mode 100644 index dae4274..0000000 --- a/user_test.go +++ /dev/null @@ -1,110 +0,0 @@ -package hotline - -import ( - "github.com/stretchr/testify/assert" - "testing" -) - -func TestReadUser(t *testing.T) { - type args struct { - b []byte - } - tests := []struct { - name string - args args - want *User - wantErr bool - }{ - { - name: "returns expected User struct", - args: args{ - b: []byte{ - 0x00, 0x01, - 0x07, 0xd0, - 0x00, 0x01, - 0x00, 0x03, - 0x61, 0x61, 0x61, - }, - }, - want: &User{ - ID: []byte{ - 0x00, 0x01, - }, - Icon: []byte{ - 0x07, 0xd0, - }, - Flags: []byte{ - 0x00, 0x01, - }, - Name: "aaa", - }, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ReadUser(tt.args.b) - if (err != nil) != tt.wantErr { - t.Errorf("ReadUser() error = %v, wantErr %v", err, tt.wantErr) - return - } - if !assert.Equal(t, tt.want, got) { - t.Errorf("ReadUser() got = %v, want %v", got, tt.want) - } - }) - } -} - -func TestDecodeUserString(t *testing.T) { - type args struct { - encodedString []byte - } - tests := []struct { - name string - args args - wantDecodedString string - }{ - { - name: "decodes bytes to guest", - args: args{ - encodedString: []byte{ - 0x98, 0x8a, 0x9a, 0x8c, 0x8b, - }, - }, - wantDecodedString: "guest", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if gotDecodedString := DecodeUserString(tt.args.encodedString); gotDecodedString != tt.wantDecodedString { - t.Errorf("DecodeUserString() = %v, want %v", gotDecodedString, tt.wantDecodedString) - } - }) - } -} - -func TestNegatedUserString(t *testing.T) { - type args struct { - encodedString []byte - } - tests := []struct { - name string - args args - want string - }{ - { - name: "encodes bytes to string", - args: args{ - encodedString: []byte("guest"), - }, - want: string([]byte{0x98, 0x8a, 0x9a, 0x8c, 0x8b}), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := NegatedUserString(tt.args.encodedString); got != tt.want { - t.Errorf("NegatedUserString() = %v, want %v", got, tt.want) - } - }) - } -} diff --git a/version.go b/version.go deleted file mode 100644 index d24a4bc..0000000 --- a/version.go +++ /dev/null @@ -1,3 +0,0 @@ -package hotline - -const VERSION = "0.0.6" -- cgit