From 6988a0571d5d37ea0f38ee3e4066533158f608bc Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Sat, 24 Jul 2021 17:54:17 -0700 Subject: Initial squashed commit --- .circleci/config.yml | 86 ++ .gitignore | 24 + .run/go run server.go.run.xml | 12 + Dockerfile | 12 + LICENSE.txt | 21 + Makefile | 5 + README.md | 83 ++ access.go | 59 + account.go | 54 + client.go | 980 +++++++++++++++ client/banners/1.txt | 6 + client/banners/2.txt | 9 + client/banners/3.txt | 6 + client/banners/4.txt | 7 + client/banners/5.txt | 11 + client/banners/6.txt | 12 + client/banners/7.txt | 7 + client/banners/8.txt | 10 + client/banners/9.txt | 9 + client/main.go | 103 ++ client/mobius-client-config.yaml | 7 + client_conn.go | 247 ++++ client_conn_test.go | 53 + concat/slices.go | 18 + config.go | 21 + docs/Hotline login sequence.md | 128 ++ 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 + go.mod | 21 + go.sum | 105 ++ news.go | 274 +++++ server.go | 1049 ++++++++++++++++ server/main.go | 73 ++ server/mobius/config/Agreement.txt | 1 + server/mobius/config/MessageBoard.txt | 1 + server/mobius/config/ThreadedNews.yaml | 1 + server/mobius/config/Users/admin.yaml | 12 + server/mobius/config/Users/guest.yaml | 12 + server/mobius/config/config.yaml | 12 + server_blackbox_test.go | 321 +++++ server_test.go | 806 ++++++++++++ stats.go | 33 + test/.DS_Store | Bin 0 -> 6148 bytes test/config/.DS_Store | Bin 0 -> 6148 bytes test/config/Agreement.txt | 1 + test/config/Files/test/testfile-1k | Bin 0 -> 1024 bytes test/config/Files/test/testfile-5k | Bin 0 -> 5120 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 0 -> 6148 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 | 1550 ++++++++++++++++++++++++ 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 + 75 files changed, 8706 insertions(+) create mode 100644 .circleci/config.yml create mode 100644 .gitignore create mode 100644 .run/go run server.go.run.xml create mode 100644 Dockerfile create mode 100644 LICENSE.txt create mode 100644 Makefile create mode 100644 README.md create mode 100644 access.go create mode 100644 account.go create mode 100644 client.go create mode 100644 client/banners/1.txt create mode 100644 client/banners/2.txt create mode 100644 client/banners/3.txt create mode 100644 client/banners/4.txt create mode 100644 client/banners/5.txt create mode 100644 client/banners/6.txt create mode 100644 client/banners/7.txt create mode 100644 client/banners/8.txt create mode 100644 client/banners/9.txt create mode 100644 client/main.go create mode 100644 client/mobius-client-config.yaml create mode 100644 client_conn.go create mode 100644 client_conn_test.go create mode 100644 concat/slices.go create mode 100644 config.go create mode 100644 docs/Hotline login sequence.md create mode 100644 field.go create mode 100644 field_test.go create mode 100644 file_header.go create mode 100644 file_header_test.go create mode 100644 file_path.go create mode 100644 file_transfer.go create mode 100644 files.go create mode 100644 files_test.go create mode 100644 flattened_file_object.go create mode 100644 flattened_file_object_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 news.go create mode 100644 server.go create mode 100644 server/main.go create mode 100644 server/mobius/config/Agreement.txt create mode 100644 server/mobius/config/MessageBoard.txt create mode 100644 server/mobius/config/ThreadedNews.yaml create mode 100644 server/mobius/config/Users/admin.yaml create mode 100644 server/mobius/config/Users/guest.yaml create mode 100644 server/mobius/config/config.yaml create mode 100644 server_blackbox_test.go create mode 100644 server_test.go create mode 100644 stats.go create mode 100644 test/.DS_Store create mode 100644 test/config/.DS_Store create mode 100644 test/config/Agreement.txt create mode 100644 test/config/Files/test/testfile-1k create mode 100644 test/config/Files/test/testfile-5k create mode 100644 test/config/Files/testdir/some-nested-file.txt create mode 100644 test/config/Files/testfile.sit create mode 100644 test/config/Files/testfile.txt create mode 100644 test/config/MessageBoard.txt create mode 100644 test/config/ThreadedNews.yaml create mode 100644 test/config/Users/.DS_Store create mode 100644 test/config/Users/admin.yaml create mode 100644 test/config/Users/guest.yaml create mode 100644 test/config/config.yaml create mode 100644 tracker.go create mode 100644 tracker_test.go create mode 100644 transaction.go create mode 100644 transaction_handlers.go create mode 100644 transaction_handlers_test.go create mode 100644 transaction_test.go create mode 100644 transfer.go create mode 100644 transfer_test.go create mode 100644 user.go create mode 100644 user_test.go create mode 100644 version.go diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..4ac6313 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,86 @@ +# Use the latest 2.1 version of CircleCI pipeline process engine. See: https://circleci.com/docs/2.0/configuration-reference +version: 2.1 +jobs: + build: + working_directory: ~/repo + docker: + - image: cimg/go:1.16.6 + steps: + - checkout +# - restore_cache: +# keys: +# - go-mod-v4-{{ checksum "go.sum" }} + - run: + name: Install Dependencies + command: go mod download +# - save_cache: +# key: go-mod-v4-{{ checksum "go.sum" }} +# paths: +# - "/go/pkg/mod" + - run: + name: Run tests + command: | + mkdir -p /tmp/test-reports + gotestsum --junitfile /tmp/test-reports/unit-tests.xml + - store_test_results: + path: /tmp/test-reports + + deploy: + docker: + - image: cimg/go:1.16.6 +# working_directory: /go/src/github.com/jhalter/mobius + steps: + - checkout + - run: go get -u github.com/mitchellh/gox + - run: go get -u github.com/tcnksm/ghr + - run: go get -u github.com/stevenmatthewt/semantics + - run: + name: cross compile + command: | + mkdir dist + mkdir dist/mobius_server_linux_amd64 + mkdir dist/mobius_server_darwin_amd64 + mkdir dist/mobius_server_linux_arm + + cd server + + cp -r mobius/config ../dist/mobius_server_linux_amd64/config + cp -r mobius/config ../dist/mobius_server_darwin_amd64/config + cp -r mobius/config ../dist/mobius_server_linux_arm/config + + gox -os="linux" -arch="amd64" -output="../dist/mobius_server_linux_amd64/mobius_server" + gox -os="darwin" -arch="amd64" -output="../dist/mobius_server_darwin_amd64/mobius_server" + gox -os="linux" -arch="arm" -output="../dist/mobius_server_linux_arm/mobius_server" + + cd ../client + gox -os="linux" -arch="amd64" -output="../dist/mobius_client_linux_amd64/mobius_client" + gox -os="darwin" -arch="amd64" -output="../dist/mobius_client_darwin_amd64/mobius_client" + cd ../dist + + tar -zcvf mobius_server_linux_amd64.tar.gz mobius_server_linux_amd64 + tar -zcvf mobius_server_darwin_amd64.tar.gz mobius_server_darwin_amd64 + tar -zcvf mobius_server_linux_arm.tar.gz mobius_server_linux_arm + tar -zcvf mobius_client_linux_amd64.tar.gz mobius_client_linux_amd64 + tar -zcvf mobius_client_darwin_amd64.tar.gz mobius_client_darwin_amd64 + - add_ssh_keys + - run: + name: create release + command: | + tag=$(semantics --output-tag) + if [ "$tag" ]; then + ghr -t $GITHUB_TOKEN -u $CIRCLE_PROJECT_USERNAME -r $CIRCLE_PROJECT_REPONAME --replace $tag dist/ + else + echo "The commit message(s) did not indicate a major/minor/patch version." + fi + +workflows: + version: 2 + build-deploy: + jobs: + - build + - deploy: + requires: + - build + filters: + branches: + only: master diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..903ecb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out +coverage.html + +# Dependency directories (remove the comment below to include it) +# vendor/ +# +server/config/files/* +.idea/* + +**/.DS_Store +build + +*log \ No newline at end of file diff --git a/.run/go run server.go.run.xml b/.run/go run server.go.run.xml new file mode 100644 index 0000000..efbc814 --- /dev/null +++ b/.run/go run server.go.run.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..156de2d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.14 + +WORKDIR /app +COPY . . + +RUN go build -o /app/server/server /app/server/server.go \ + && chmod a+x /app/server/server + +EXPOSE 5500 5501 5502 + +WORKDIR /app/server/ +CMD ["server"] \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..392591b --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Jeff Halter + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fb14198 --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +build-client: + go build -o mobius-hotline-client client/main.go + +build-server: + go build -o mobius-hotline-server server/main.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..799a903 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Mobius + +Cross-platform command line [Hotline](https://en.wikipedia.org/wiki/Hotline_Communications) server and client + +[![CircleCI](https://circleci.com/gh/jhalter/mobius/tree/master.svg?style=svg&circle-token=7123999e4511cf3eb93d76de98b614a803207bea)](https://circleci.com/gh/jhalter/mobius/tree/master) + +# Installation + +### Mac OS X + +#### Client + + brew install jhalter/mobius-hotline-client/mobius-hotline-client + +#### Server + + brew install jhalter/mobius-hotline-client/mobius-hotline-client + +### Linux + +Download a compiled release for your architecture from the Releases page + +### Windows + + TODO + +# Build + +To build from source, run +`make build` + +# Features + +* Hotline 1.2.3 + +## Usage + +### Precompiled binaries +To get started quickly, download the precompiled binaries for your platform: + +* [Linux]() +* [Mac OS X]() + +## Compatibility + +The server has been tested with: + * Hotline Client version 1.2.3 + * Hotline Client version 1.8.2 + * Hotline Client version 1.9.2 + * Nostalgia + +### Build from source + + make build + + +# TODO + +* Implement 1.5+ protocol account editing +* Implement folder transfer resume +* Implement file transfer queuing +* Map additional file extensions to file type and creator codes +* Implement file comment read/write +* Implement user banning +* Implement Maximum Simultaneous Downloads +* Maximum simultaneous downloads/client +* Maximum simultaneous connections/IP +* Implement server broadcast +* Implement statistics: + * Currently Connected + * Downloads in progress + * Uploads in progress + * Waiting Downloads + * Connection Peak + * Connection Counter + * Download Counter + * Upload Counter + * Since + + +# TODO - Someday Maybe + +* Implement Pitbull protocol extensions \ No newline at end of file diff --git a/access.go b/access.go new file mode 100644 index 0000000..21b438f --- /dev/null +++ b/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/account.go b/account.go new file mode 100644 index 0000000..b0e9ee5 --- /dev/null +++ b/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/client.go b/client.go new file mode 100644 index 0000000..59da989 --- /dev/null +++ b/client.go @@ -0,0 +1,980 @@ +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" + "strings" + "time" +) + +const clientConfigPath = "/usr/local/etc/mobius-client-config.yaml" + +//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"` +} + +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 + UserName []byte + Login *[]byte + Password *[]byte + Icon *[]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). + SetText(""). + 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). + //SetFieldTextColor(tcell.ColorWhite). + 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) + userList.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, + } +} + +const defaultUsername = "unnamed" + +const ( + trackerListPage = "trackerList" +) + +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("hltracker.com:5498") + 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 { + settingsForm := tview.NewForm() + settingsForm.AddInputField("Your Name", ui.HLClient.pref.Username, 20, nil, nil) + settingsForm.AddButton("Save", func() { + ui.HLClient.pref.Username = settingsForm.GetFormItem(0).(*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 +} + +var ( + srvIP string + srvLogin string + srvPass string +) + +// 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 { + srvIP = server + joinServerForm := tview.NewForm() + joinServerForm. + AddInputField("Server", server, 20, nil, func(text string) { + srvIP = text + }). + AddInputField("Login", login, 20, nil, func(text string) { + l := []byte(text) + ui.HLClient.Login = &l + }). + AddPasswordField("Password", password, 20, '*', nil). + AddCheckbox("Save", save, func(checked bool) { + // TODO + }). + 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, _ := os.ReadFile("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\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) + } + } + + 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), + 13, 1, false) + home.AddItem(tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(mainMenu, 0, 1, true). + AddItem(nil, 0, 1, false), + 0, 1, true, + ) + + joinServerPage := ui.renderJoinServerForm("", GuestAccount, "", "home", false, false) + + mainMenu.AddItem("Join Server", "", 'j', func() { + 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, false) + + 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 { + //curPage, _ := ui.Pages.GetFrontPage() + 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.SwitchToPage("serverUI") + 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 { + panic(err) + } +} + +func NewClient(username string, logger *zap.SugaredLogger) *Client { + c := &Client{ + Icon: &[]byte{0x07, 0xd7}, + 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 { + u, _ := ReadUser(field.Data) + //flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags))) + //if flagBitmap.Bit(userFlagAdmin) == 1 { + // fmt.Fprintf(UserList, "[red::b]%s[-:-:-]\n", u.Name) + //} else { + // fmt.Fprintf(UserList, "%s\n", u.Name) + //} + + 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.Icon), + 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, []byte{0x07, 0xd1}), + NewField(fieldUserLogin, []byte(NegatedUserString([]byte(login)))), + NewField(fieldUserPassword, []byte(NegatedUserString([]byte(password)))), + NewField(fieldVersion, []byte{0, 2}), + ), + ) +} + +//// Agree agrees to the server agreement and sends user info, completing the login sequence +//func (c *Client) Agree() { +// c.Send( +// NewTransaction( +// tranAgreed, 3, +// []Field{ +// NewField(fieldUserName, []byte("test")), +// NewField(fieldUserIconID, *c.Icon), +// NewField(fieldUserFlags, []byte{0x00, 0x00}), +// }, +// ), +// ) +// // +// //// Block until we receive the agreement reply from the server +// //_ = c.WaitForTransaction(tranAgreed) +//} + +//func (c *Client) WaitForTransaction(id uint16) Transaction { +// var trans Transaction +// for { +// buf := make([]byte, 1400) +// readLen, err := c.Connection.Read(buf) +// if err != nil { +// panic(err) +// } +// +// transactions := ReadTransactions(buf[:readLen]) +// tran, err := FindTransactions(id, transactions) +// if err == nil { +// fmt.Println("returning") +// return tran +// } +// } +// +// return trans +//} + +//func (c *Client) Read() error { +// // Main loop where we wait for and take action on client requests +// for { +// buf := make([]byte, 1400) +// readLen, err := c.Connection.Read(buf) +// if err != nil { +// panic(err) +// } +// transactions, _, _ := readTransactions(buf[:readLen]) +// +// for _, t := range transactions { +// c.HandleTransaction(&t) +// } +// } +// +// return nil +//} + +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 new file mode 100644 index 0000000..608a316 --- /dev/null +++ b/client/banners/1.txt @@ -0,0 +1,6 @@ + __ __ ______ ______ __ __ __ __ ______ +/\ \_\ \ /\ __ \ /\__ _\ /\ \ /\ \ /\ "-.\ \ /\ ___\ +\ \ __ \ \ \ \/\ \ \/_/\ \/ \ \ \____ \ \ \ \ \ \-. \ \ \ __\ + \ \_\ \_\ \ \_____\ \ \_\ \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ + \/_/\/_/ \/_____/ \/_/ \/_____/ \/_/ \/_/ \/_/ \/_____/ + \ No newline at end of file diff --git a/client/banners/2.txt b/client/banners/2.txt new file mode 100644 index 0000000..1121e06 --- /dev/null +++ b/client/banners/2.txt @@ -0,0 +1,9 @@ + ▄█ █▄ ▄██████▄ ███ ▄█ ▄█ ███▄▄▄▄ ▄████████ + ███ ███ ███ ███ ▀█████████▄ ███ ███ ███▀▀▀██▄ ███ ███ + ███ ███ ███ ███ ▀███▀▀██ ███ ███▌ ███ ███ ███ █▀ + ▄███▄▄▄▄███▄▄ ███ ███ ███ ▀ ███ ███▌ ███ ███ ▄███▄▄▄ +▀▀███▀▀▀▀███▀ ███ ███ ███ ███ ███▌ ███ ███ ▀▀███▀▀▀ + ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ █▄ + ███ ███ ███ ███ ███ ███▌ ▄ ███ ███ ███ ███ ███ + ███ █▀ ▀██████▀ ▄████▀ █████▄▄██ █▀ ▀█ █▀ ██████████ + ▀ \ No newline at end of file diff --git a/client/banners/3.txt b/client/banners/3.txt new file mode 100644 index 0000000..608a316 --- /dev/null +++ b/client/banners/3.txt @@ -0,0 +1,6 @@ + __ __ ______ ______ __ __ __ __ ______ +/\ \_\ \ /\ __ \ /\__ _\ /\ \ /\ \ /\ "-.\ \ /\ ___\ +\ \ __ \ \ \ \/\ \ \/_/\ \/ \ \ \____ \ \ \ \ \ \-. \ \ \ __\ + \ \_\ \_\ \ \_____\ \ \_\ \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\ + \/_/\/_/ \/_____/ \/_/ \/_____/ \/_/ \/_/ \/_/ \/_____/ + \ No newline at end of file diff --git a/client/banners/4.txt b/client/banners/4.txt new file mode 100644 index 0000000..3b9d720 --- /dev/null +++ b/client/banners/4.txt @@ -0,0 +1,7 @@ +.___.__  ._______  _____._.___    .___ .______  ._______ +:   |  \ : .___  \ \__ _:||   |   : __|:      \ : .____/ +|   :   || :   |  |  |  :||   |   | : ||       || : _/\  +|   .   ||     :  |  |   ||   |/\ |   ||   |   ||   /  \ +|___|   | \_. ___/   |   ||   /  \|   ||___|   ||_.: __/ +    |___|   :/       |___||______/|___|    |___|   :/    +    :                                        diff --git a/client/banners/5.txt b/client/banners/5.txt new file mode 100644 index 0000000..1d468d5 --- /dev/null +++ b/client/banners/5.txt @@ -0,0 +1,11 @@ + ,ggg, gg +dP""Y8b 88 I8 ,dPYb, +Yb, `88 88 I8 IP'`Yb + `" 88 88 88888888 I8 8I gg + 88aaaaaaa88 I8 I8 8' "" + 88"""""""88 ,ggggg, I8 I8 dP gg ,ggg,,ggg, ,ggg, + 88 88 dP" "Y8ggg I8 I8dP 88 ,8" "8P" "8, i8" "8i + 88 88 i8' ,8I ,I8, I8P 88 I8 8I 8I I8, ,8I + 88 Y8,,d8, ,d8' ,d88b, ,d8b,_ _,88,_,dP 8I Yb, `YbadP' + 88 `Y8P"Y8888P" 88P""Y888P'"Y888P""Y88P' 8I `Y8888P"Y888 + diff --git a/client/banners/6.txt b/client/banners/6.txt new file mode 100644 index 0000000..3098da2 --- /dev/null +++ b/client/banners/6.txt @@ -0,0 +1,12 @@ + +@@@ @@@ @@@@@@ @@@@@@@ @@@ @@@ @@@ @@@ @@@@@@@@ +@@@ @@@ @@@@@@@@ @@@@@@@ @@@ @@@ @@@@ @@@ @@@@@@@@ +@@! @@@ @@! @@@ @@! @@! @@! @@!@!@@@ @@! +!@! @!@ !@! @!@ !@! !@! !@! !@!!@!@! !@! +@!@!@!@! @!@ !@! @!! @!! !!@ @!@ !!@! @!!!:! +!!!@!!!! !@! !!! !!! !!! !!! !@! !!! !!!!!: +!!: !!! !!: !!! !!: !!: !!: !!: !!! !!: +:!: !:! :!: !:! :!: :!: :!: :!: !:! :!: +:: ::: ::::: :: :: :: :::: :: :: :: :: :::: + : : : : : : : : :: : : : :: : : :: :: + diff --git a/client/banners/7.txt b/client/banners/7.txt new file mode 100644 index 0000000..c072e25 --- /dev/null +++ b/client/banners/7.txt @@ -0,0 +1,7 @@ +██╗ ██╗ ██████╗ ████████╗██╗ ██╗███╗ ██╗███████╗ +██║ ██║██╔═══██╗╚══██╔══╝██║ ██║████╗ ██║██╔════╝ +███████║██║ ██║ ██║ ██║ ██║██╔██╗ ██║█████╗ +██╔══██║██║ ██║ ██║ ██║ ██║██║╚██╗██║██╔══╝ +██║ ██║╚██████╔╝ ██║ ███████╗██║██║ ╚████║███████╗ +╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝ + diff --git a/client/banners/8.txt b/client/banners/8.txt new file mode 100644 index 0000000..d75e153 --- /dev/null +++ b/client/banners/8.txt @@ -0,0 +1,10 @@ + ██░ ██ ▒█████ ▄▄▄█████▓ ██▓ ██▓ ███▄ █ ▓█████ +▓██░ ██▒▒██▒ ██▒▓ ██▒ ▓▒▓██▒ ▓██▒ ██ ▀█ █ ▓█ ▀ +▒██▀▀██░▒██░ ██▒▒ ▓██░ ▒░▒██░ ▒██▒▓██ ▀█ ██▒▒███ +░▓█ ░██ ▒██ ██░░ ▓██▓ ░ ▒██░ ░██░▓██▒ ▐▌██▒▒▓█ ▄ +░▓█▒░██▓░ ████▓▒░ ▒██▒ ░ ░██████▒░██░▒██░ ▓██░░▒████▒ + ▒ ░░▒░▒░ ▒░▒░▒░ ▒ ░░ ░ ▒░▓ ░░▓ ░ ▒░ ▒ ▒ ░░ ▒░ ░ + ▒ ░▒░ ░ ░ ▒ ▒░ ░ ░ ░ ▒ ░ ▒ ░░ ░░ ░ ▒░ ░ ░ ░ + ░ ░░ ░░ ░ ░ ▒ ░ ░ ░ ▒ ░ ░ ░ ░ ░ + ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ + diff --git a/client/banners/9.txt b/client/banners/9.txt new file mode 100644 index 0000000..eba9134 --- /dev/null +++ b/client/banners/9.txt @@ -0,0 +1,9 @@ + █████ █████ █████ ████ ███ +░░███ ░░███ ░░███ ░░███ ░░░ + ░███ ░███ ██████ ███████ ░███ ████ ████████ ██████ + ░███████████ ███░░███░░░███░ ░███ ░░███ ░░███░░███ ███░░███ + ░███░░░░░███ ░███ ░███ ░███ ░███ ░███ ░███ ░███ ░███████ + ░███ ░███ ░███ ░███ ░███ ███ ░███ ░███ ░███ ░███ ░███░░░ + █████ █████░░██████ ░░█████ █████ █████ ████ █████░░██████ +░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░ ░░░░░ ░░░░░░ + diff --git a/client/main.go b/client/main.go new file mode 100644 index 0000000..a761404 --- /dev/null +++ b/client/main.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "flag" + "fmt" + hotline "github.com/jhalter/mobius" + "github.com/rivo/tview" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "os" + "os/signal" + "syscall" + "time" +) + +//var defaultTrackerList = []string{ +// "hltracker.com:5498", +//} + +const connectTimeout = 3 * time.Second + +func main() { + _, cancelRoot := context.WithCancel(context.Background()) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT, os.Interrupt) + + version := flag.Bool("version", false, "print version and exit") + logLevel := flag.String("log-level", "info", "Log level") + userName := flag.String("name", "unnamed", "User name") + //srvAddr := flag.String("server", "localhost:5500", "Hostname/Port of server") + //login := flag.String("login", "guest", "Login Name") + //pass := flag.String("password", "", "Login Password") + flag.Parse() + + if *version { + fmt.Printf("v%s\n", hotline.VERSION) + os.Exit(0) + } + + zapLvl, ok := zapLogLevel[*logLevel] + if !ok { + fmt.Printf("Invalid log level %s. Must be debug, info, warn, or error.\n", *logLevel) + os.Exit(0) + } + + // init DebugBuffer + db := &hotline.DebugBuffer{ + TextView: tview.NewTextView(), + } + + cores := []zapcore.Core{ + newDebugCore(zapLvl, db), + //newStderrCore(zapLvl), + } + l := zap.New(zapcore.NewTee(cores...)) + defer func() { _ = l.Sync() }() + logger := l.Sugar() + logger.Infow("Started Mobius client", "Version", hotline.VERSION) + + go func() { + sig := <-sigChan + logger.Infow("Stopping client", "signal", sig.String()) + cancelRoot() + }() + + client := hotline.NewClient(*userName, logger) + client.DebugBuf = db + client.UI.Start() + +} + +func newDebugCore(level zapcore.Level, db *hotline.DebugBuffer) zapcore.Core { + encoderCfg := zap.NewProductionEncoderConfig() + encoderCfg.TimeKey = "timestamp" + encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder + + return zapcore.NewCore( + zapcore.NewConsoleEncoder(encoderCfg), + zapcore.Lock(db), + level, + ) +} + +func newStderrCore(level zapcore.Level) zapcore.Core { + encoderCfg := zap.NewProductionEncoderConfig() + encoderCfg.TimeKey = "timestamp" + encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder + + return zapcore.NewCore( + zapcore.NewConsoleEncoder(encoderCfg), + zapcore.Lock(os.Stderr), + level, + ) +} + +var zapLogLevel = map[string]zapcore.Level{ + "debug": zap.DebugLevel, + "info": zap.InfoLevel, + "warn": zap.WarnLevel, + "error": zap.ErrorLevel, +} diff --git a/client/mobius-client-config.yaml b/client/mobius-client-config.yaml new file mode 100644 index 0000000..c46d269 --- /dev/null +++ b/client/mobius-client-config.yaml @@ -0,0 +1,7 @@ +Username: unnamed +IconID: 2000 +Bookmarks: + - Name: Example Server + Addr: localhost:5500 + Login: guest + Password: "" diff --git a/client_conn.go b/client_conn.go new file mode 100644 index 0000000..5c25f92 --- /dev/null +++ b/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/client_conn_test.go b/client_conn_test.go new file mode 100644 index 0000000..d18f9b6 --- /dev/null +++ b/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/concat/slices.go b/concat/slices.go new file mode 100644 index 0000000..daf8aae --- /dev/null +++ b/concat/slices.go @@ -0,0 +1,18 @@ +package concat + +// Slices is a utility function to make appending multiple slices less painful and more efficient +// Source: https://stackoverflow.com/questions/37884361/concat-multiple-slices-in-golang +func Slices(slices ...[]byte) []byte { + var totalLen int + for _, s := range slices { + totalLen += len(s) + } + tmp := make([]byte, totalLen) + var i int + for _, s := range slices { + i += copy(tmp[i:], s) + } + + return tmp +} + diff --git a/config.go b/config.go new file mode 100644 index 0000000..e0c20cf --- /dev/null +++ b/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/docs/Hotline login sequence.md b/docs/Hotline login sequence.md new file mode 100644 index 0000000..1ce0fba --- /dev/null +++ b/docs/Hotline login sequence.md @@ -0,0 +1,128 @@ +## Hotline v.1.2.3 Login Sequence + +### TLDR + +1. TCP handshake +2. Hotline proto handshake +3. Client fires off: + + a. Login transaction (107) + + b. tranGetUserNameList (300) + + c. tranGetMsg (101) + +4. Server replies to each + +### Long version + +1. Client -> Server TCP handshake +2. Hotline proto handshake (TRTPHOTL) +3. Client sends Login transaction (107) + + ``` + 00. // flags + 00 // isReply + 00 6b // transaction type: Login transaction (107) + 00 00 00 01 // ID + 00 00 00 00 // error code + 00 00 00 13 // total size + 00 00 00 13 // data size + + 00 02 // field count + + 00 66 // fieldUserName (102) + 00 07 // field length + 75 6e 6e 61 6d 65 64 // unnamed + + 00 68 // fieldUserIconID (104) + 00 02 // field length + 07 d1 // 1233 + ``` + +4. Server sends empty reply to login transaction (107) + + 00 + 01 + 00 00 + 00 00 00 01 + 00 00 00 00 + 00 00 00 02 + 00 00 00 02 + 00 00 + + +5. Client sends tranGetUserNameList (300) (with some weird extra data at the end??) + + + 00 + 00 + 01 2c // tranGetUserNameList (300) + 00 00 00 02 // ID + 00 00 00 00 // Error Code + 00 00 00 02 + 00 00 00 02 + 00 00 + 00 00 + 00 65 00 00 00 03 00 00 00 00 00 00 + + 00 02 + 00 00 + + 00 02 + 00 00 + + +Server sends tranServerMsg + + 00 + 00 + 00 68 // tranServerMsg (104) + 00 00 00 01 + 00 00 00 00 + 00 00 00 a4 + 00 00 00 a4 + + 00 01 + + 00 65 // fieldData (101) + 00 9e + 54 68 65 20 73 65 72 76 65 72 20 79 6f 75 + 20 61 72 65 20 63 6f 6e 6e 65 63 74 65 64 20 74 + 6f 20 69 73 20 6e 6f 74 20 6c 69 63 65 6e 73 65 + 64 20 61 6e 64 20 69 73 20 66 6f 72 20 65 76 61 + 6c 75 61 74 69 6f 6e 20 75 73 65 20 6f 6e 6c 79 + 2e 20 50 6c 65 61 73 65 20 65 6e 63 6f 75 72 61 + 67 65 20 74 68 65 20 61 64 6d 69 6e 69 73 74 72 + 61 74 6f 72 20 6f 66 20 74 68 69 73 20 73 65 72 + 76 65 72 20 74 6f 20 70 75 72 63 68 61 73 65 20 + 61 20 6c 69 63 65 6e 73 65 64 20 63 6f 70 79 2e + + +## Hotline 1.9.2 Login Sequence + + +1. Client/Server TCP handshake +2. Hotline Proto handshake +2. Client sends Login transaction (107) + - 105: UserLogin + - 106: UserPassword + - 160: Version +3. Server sends reply with no type and fields: + - 160: Version + - 161: Banner ID + - 162: Server Name +4. Server sends Agreement transaction (109) +5. Client sends Agreed transaction (121) + - 102: User name + - 104: User icon ID + - 113: Options + - 215: Automatic Reponse (optional) +6. Server sends reply with no type and no fields +7. Server sends User Access (354) + +TBD + +``` + +``` diff --git a/field.go b/field.go new file mode 100644 index 0000000..161a9b2 --- /dev/null +++ b/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/field_test.go b/field_test.go new file mode 100644 index 0000000..fb686dc --- /dev/null +++ b/field_test.go @@ -0,0 +1,7 @@ +package hotline + +import "testing" + +func TestHello(t *testing.T) { + +} diff --git a/file_header.go b/file_header.go new file mode 100644 index 0000000..60c652e --- /dev/null +++ b/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/file_header_test.go b/file_header_test.go new file mode 100644 index 0000000..0194a60 --- /dev/null +++ b/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/file_path.go b/file_path.go new file mode 100644 index 0000000..71168dc --- /dev/null +++ b/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/file_transfer.go b/file_transfer.go new file mode 100644 index 0000000..46b0061 --- /dev/null +++ b/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/files.go b/files.go new file mode 100644 index 0000000..69c22b3 --- /dev/null +++ b/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/files_test.go b/files_test.go new file mode 100644 index 0000000..88c893f --- /dev/null +++ b/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/flattened_file_object.go b/flattened_file_object.go new file mode 100644 index 0000000..dfdd8a8 --- /dev/null +++ b/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/flattened_file_object_test.go b/flattened_file_object_test.go new file mode 100644 index 0000000..6a6953f --- /dev/null +++ b/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/go.mod b/go.mod new file mode 100644 index 0000000..33a8ea7 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module github.com/jhalter/mobius + +go 1.16 + +require ( + github.com/davecgh/go-spew v1.1.1 + github.com/gdamore/tcell/v2 v2.3.3 + github.com/mbndr/figlet4go v0.0.0-20190224160619-d6cef5b186ea // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/tview v0.0.0-20210624165335-29d673af0ce2 + github.com/stretchr/testify v1.4.0 + go.uber.org/zap v1.15.0 + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 + golang.org/x/lint v0.0.0-20200302205851-738671d3881b // indirect + golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 // indirect + golang.org/x/text v0.3.6 // indirect + golang.org/x/tools v0.0.0-20200825202427-b303f430e36d // indirect + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect + gopkg.in/yaml.v2 v2.2.4 + honnef.co/go/tools v0.0.1-2020.1.4 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b3cc040 --- /dev/null +++ b/go.sum @@ -0,0 +1,105 @@ +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.3.3 h1:RKoI6OcqYrr/Do8yHZklecdGzDTJH9ACKdfECbRdw3M= +github.com/gdamore/tcell/v2 v2.3.3/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mbndr/figlet4go v0.0.0-20190224160619-d6cef5b186ea h1:mQncVDBpKkAecPcH2IMGpKUQYhwowlafQbfkz2QFqkc= +github.com/mbndr/figlet4go v0.0.0-20190224160619-d6cef5b186ea/go.mod h1:QzTGLGoOqLHUBK8/EZ0v4Fa4CdyXmdyRwCHcl0YbeO4= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/tview v0.0.0-20210624165335-29d673af0ce2 h1:I5N0WNMgPSq5NKUFspB4jMJ6n2P0ipz5FlOlB4BXviQ= +github.com/rivo/tview v0.0.0-20210624165335-29d673af0ce2/go.mod h1:IxQujbYMAh4trWr0Dwa8jfciForjVmxyHpskZX6aydQ= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/multierr v1.5.0 h1:KCa4XfM8CWFCpxXRGok+Q0SS/0XBhMDbHHGABQLvD2A= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.15.0 h1:ZZCA22JRF2gQE5FoNmhmrf7jeJJ2uhqDUNRYKm8dvmM= +go.uber.org/zap v1.15.0/go.mod h1:Mb2vm2krFEG5DV0W9qcHBYFtp/Wku1cvYaqPsS/WYfc= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b h1:Wh+f8QHJXR411sJR8/vRBTZ7YapZaRvUcLFFJhusH0k= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210309074719-68d13333faf2/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 h1:RqytpXGR1iVNX7psjB3ff8y7sNFinVFvkx1c8SjBkio= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d h1:SZxvLBoTP5yHO3Frd4z4vrF+DBX9vMVanchswa69toE= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d h1:W07d4xkoAUSNOkOzdzXCdFGxT7o2rW4q8M34tB2i//k= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.4 h1:UoveltGrhghAA7ePc+e+QYDHXrBps2PqFZiHkGR/xK8= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= diff --git a/news.go b/news.go new file mode 100644 index 0000000..a9e5d72 --- /dev/null +++ b/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/server.go b/server.go new file mode 100644 index 0000000..002eb40 --- /dev/null +++ b/server.go @@ -0,0 +1,1049 @@ +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") + } + + // Hotline 1.2.3 client does not send fieldVersion + // Nostalgia client sends "" + //if string(*c.Version) == "" { + // *c.UserName = clientLogin.GetField(fieldUserName).Data + // *c.Icon = clientLogin.GetField(fieldUserIconID).Data + //} + // + 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)) + + // The Hotline ClientConn v1.2.3 has a different login sequence than 1.9.2 + if string(*c.Version) == "" { + if _, err := c.notifyNewUserHasJoined(); err != nil { + return err + } + } + + 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 new file mode 100644 index 0000000..0b9a09c --- /dev/null +++ b/server/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "flag" + "fmt" + "github.com/jhalter/mobius" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "os" +) + +const ( + defaultConfigPath = "/usr/local/var/mobius/config/" // matches Homebrew default config location + defaultPort = 5500 +) + +func main() { + ctx, cancelRoot := context.WithCancel(context.Background()) + + basePort := flag.Int("bind", defaultPort, "Bind address and port") + configDir := flag.String("config", defaultConfigPath, "Path to config root") + version := flag.Bool("version", false, "print version and exit") + logLevel := flag.String("log-level", "info", "Log level") + flag.Parse() + + if *version { + fmt.Printf("v%s\n", hotline.VERSION) + os.Exit(0) + } + + zapLvl, ok := zapLogLevel[*logLevel] + if !ok { + fmt.Printf("Invalid log level %s. Must be debug, info, warn, or error.\n", *logLevel) + os.Exit(0) + } + + cores := []zapcore.Core{newStdoutCore(zapLvl)} + l := zap.New(zapcore.NewTee(cores...)) + defer func() { _ = l.Sync() }() + logger := l.Sugar() + + if _, err := os.Stat(*configDir); os.IsNotExist(err) { + logger.Fatalw("Configuration directory not found", "path", configDir) + } + + srv, err := hotline.NewServer(*configDir, "", *basePort, logger) + if err != nil { + logger.Fatal(err) + } + + // Serve Hotline requests until program exit + logger.Fatal(srv.ListenAndServe(ctx, cancelRoot)) +} + +func newStdoutCore(level zapcore.Level) zapcore.Core { + encoderCfg := zap.NewProductionEncoderConfig() + encoderCfg.TimeKey = "timestamp" + encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder + + return zapcore.NewCore( + zapcore.NewConsoleEncoder(encoderCfg), + zapcore.Lock(os.Stdout), + level, + ) +} + +var zapLogLevel = map[string]zapcore.Level{ + "debug": zap.DebugLevel, + "info": zap.InfoLevel, + "warn": zap.WarnLevel, + "error": zap.ErrorLevel, +} diff --git a/server/mobius/config/Agreement.txt b/server/mobius/config/Agreement.txt new file mode 100644 index 0000000..5da4639 --- /dev/null +++ b/server/mobius/config/Agreement.txt @@ -0,0 +1 @@ +This is an agreement. Say you agree. diff --git a/server/mobius/config/MessageBoard.txt b/server/mobius/config/MessageBoard.txt new file mode 100644 index 0000000..cb36354 --- /dev/null +++ b/server/mobius/config/MessageBoard.txt @@ -0,0 +1 @@ +Welcome to Hotline diff --git a/server/mobius/config/ThreadedNews.yaml b/server/mobius/config/ThreadedNews.yaml new file mode 100644 index 0000000..dc2e8b5 --- /dev/null +++ b/server/mobius/config/ThreadedNews.yaml @@ -0,0 +1 @@ +Categories: diff --git a/server/mobius/config/Users/admin.yaml b/server/mobius/config/Users/admin.yaml new file mode 100644 index 0000000..5413735 --- /dev/null +++ b/server/mobius/config/Users/admin.yaml @@ -0,0 +1,12 @@ +Login: admin +Name: admin +Password: $2a$04$2itGEYx8C1N5bsfRSoC9JuonS3I4YfnyVPZHLSwp7kEInRX0yoB.a +Access: +- 255 +- 255 +- 255 +- 255 +- 255 +- 255 +- 255 +- 255 diff --git a/server/mobius/config/Users/guest.yaml b/server/mobius/config/Users/guest.yaml new file mode 100644 index 0000000..a1e4069 --- /dev/null +++ b/server/mobius/config/Users/guest.yaml @@ -0,0 +1,12 @@ +Login: guest +Name: guest +Password: $2a$04$9P/jgLn1fR9TjSoWL.rKxuN6g.1TSpf2o6Hw.aaRuBwrWIJNwsKkS +Access: +- 252 +- 240 +- 205 +- 201 +- 43 +- 128 +- 0 +- 0 diff --git a/server/mobius/config/config.yaml b/server/mobius/config/config.yaml new file mode 100644 index 0000000..5c8b202 --- /dev/null +++ b/server/mobius/config/config.yaml @@ -0,0 +1,12 @@ +Name: My Hotline server +Description: A default configured Hotline server running Mobius v0.0.1 +BannerID: 0 +FileRoot: Files/ +EnableTrackerRegistration: false +Trackers: +- hltracker.com:5499 +NewsDelimiter: "" +NewsDateFormat: "" +MaxDownloads: 0 +MaxDownloadsPerClient: 0 +MaxConnectionsPerIP: 0 diff --git a/server_blackbox_test.go b/server_blackbox_test.go new file mode 100644 index 0000000..3718bd2 --- /dev/null +++ b/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/server_test.go b/server_test.go new file mode 100644 index 0000000..2e88202 --- /dev/null +++ b/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/stats.go b/stats.go new file mode 100644 index 0000000..dd8ea1d --- /dev/null +++ b/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/test/.DS_Store b/test/.DS_Store new file mode 100644 index 0000000..db68ea8 Binary files /dev/null and b/test/.DS_Store differ diff --git a/test/config/.DS_Store b/test/config/.DS_Store new file mode 100644 index 0000000..b486f59 Binary files /dev/null and b/test/config/.DS_Store differ diff --git a/test/config/Agreement.txt b/test/config/Agreement.txt new file mode 100644 index 0000000..2a3bdb7 --- /dev/null +++ b/test/config/Agreement.txt @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000..31758a0 Binary files /dev/null and b/test/config/Files/test/testfile-1k differ diff --git a/test/config/Files/test/testfile-5k b/test/config/Files/test/testfile-5k new file mode 100644 index 0000000..c889187 Binary files /dev/null and b/test/config/Files/test/testfile-5k differ diff --git a/test/config/Files/testdir/some-nested-file.txt b/test/config/Files/testdir/some-nested-file.txt new file mode 100644 index 0000000..e69de29 diff --git a/test/config/Files/testfile.sit b/test/config/Files/testfile.sit new file mode 100644 index 0000000..8d1d542 --- /dev/null +++ b/test/config/Files/testfile.sit @@ -0,0 +1 @@ +nothing to see here \ No newline at end of file diff --git a/test/config/Files/testfile.txt b/test/config/Files/testfile.txt new file mode 100644 index 0000000..f0607d4 --- /dev/null +++ b/test/config/Files/testfile.txt @@ -0,0 +1 @@ +Hello, I'm a test file! \ No newline at end of file diff --git a/test/config/MessageBoard.txt b/test/config/MessageBoard.txt new file mode 100644 index 0000000..1a2f57a --- /dev/null +++ b/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/test/config/ThreadedNews.yaml b/test/config/ThreadedNews.yaml new file mode 100644 index 0000000..9f3fd65 --- /dev/null +++ b/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/test/config/Users/.DS_Store b/test/config/Users/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/test/config/Users/.DS_Store differ diff --git a/test/config/Users/admin.yaml b/test/config/Users/admin.yaml new file mode 100644 index 0000000..1bf656b --- /dev/null +++ b/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/test/config/Users/guest.yaml b/test/config/Users/guest.yaml new file mode 100644 index 0000000..57117bd --- /dev/null +++ b/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/test/config/config.yaml b/test/config/config.yaml new file mode 100644 index 0000000..cb7f15a --- /dev/null +++ b/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/tracker.go b/tracker.go new file mode 100644 index 0000000..97ca108 --- /dev/null +++ b/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/tracker_test.go b/tracker_test.go new file mode 100644 index 0000000..b904462 --- /dev/null +++ b/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/transaction.go b/transaction.go new file mode 100644 index 0000000..8fbdf35 --- /dev/null +++ b/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/transaction_handlers.go b/transaction_handlers.go new file mode 100644 index 0000000..7920eaa --- /dev/null +++ b/transaction_handlers.go @@ -0,0 +1,1550 @@ +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", + }, + 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: { + Name: "tranGetMsgs", + Handler: HandleGetMsgs, + }, + tranGetNewsArtData: { + Name: "tranGetNewsArtData", + Handler: HandleGetNewsArtData, + }, + tranGetNewsArtNameList: { + Name: "tranGetNewsArtNameList", + Handler: HandleGetNewsArtNameList, + }, + tranGetNewsCatNameList: { + 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: { + Name: "tranInviteToChat", + Handler: HandleInviteToChat, + }, + tranJoinChat: { + Name: "tranJoinChat", + Handler: HandleJoinChat, + }, + tranKeepAlive: { + Name: "tranKeepAlive", + Handler: HandleKeepAlive, + }, + tranLeaveChat: { + Name: "tranJoinChat", + Handler: HandleLeaveChat, + }, + tranNotifyDeleteUser: { + Name: "tranNotifyDeleteUser", + }, + 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: { + Name: "tranNewFolder", + Handler: HandleNewFolder, + }, + tranNewNewsCat: { + Name: "tranNewNewsCat", + Handler: HandleNewNewsCat, + }, + tranNewNewsFldr: { + Name: "tranNewNewsFldr", + Handler: HandleNewNewsFldr, + }, + tranNewUser: { + Access: accessCreateUser, + DenyMsg: "You are not allowed to create new accounts.", + Name: "tranNewUser", + Handler: HandleNewUser, + }, + tranOldPostNews: { + Name: "tranOldPostNews", + Handler: HandleTranOldPostNews, + }, + tranPostNewsArt: { + Access: accessNewsPostArt, + DenyMsg: "You are not allowed to post news articles.", + Name: "tranPostNewsArt", + Handler: HandlePostNewsArt, + }, + tranRejectChatInvite: { + Name: "tranRejectChatInvite", + Handler: HandleRejectChatInvite, + }, + tranSendInstantMsg: { + //Access: accessSendPrivMsg, + //DenyMsg: "You are not allowed to send private messages", + Name: "tranSendInstantMsg", + Handler: HandleSendInstantMsg, + RequiredFields: []requiredField{ + { + ID: fieldData, + minLen: 0, + }, + { + ID: fieldUserID, + }, + }, + }, + tranSetChatSubject: { + Name: "tranSetChatSubject", + Handler: HandleSetChatSubject, + }, + tranSetClientUserInfo: { + Access: accessAlwaysAllow, + Name: "tranSetClientUserInfo", + Handler: HandleSetClientUserInfo, + }, + tranSetFileInfo: { + 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: { + 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 new file mode 100644 index 0000000..3042da2 --- /dev/null +++ b/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/transaction_test.go b/transaction_test.go new file mode 100644 index 0000000..227b9e0 --- /dev/null +++ b/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/transfer.go b/transfer.go new file mode 100644 index 0000000..ecbe324 --- /dev/null +++ b/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/transfer_test.go b/transfer_test.go new file mode 100644 index 0000000..52044b0 --- /dev/null +++ b/transfer_test.go @@ -0,0 +1,7 @@ +package hotline + +import "testing" + +func TestReadTransfer(t *testing.T) { + +} diff --git a/user.go b/user.go new file mode 100644 index 0000000..f80fd72 --- /dev/null +++ b/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/user_test.go b/user_test.go new file mode 100644 index 0000000..dae4274 --- /dev/null +++ b/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/version.go b/version.go new file mode 100644 index 0000000..6335214 --- /dev/null +++ b/version.go @@ -0,0 +1,3 @@ +package hotline + +const VERSION = "0.0.1" -- cgit