From: Jeff Halter Date: Sun, 25 Jul 2021 00:54:17 +0000 (-0700) Subject: Initial squashed commit X-Git-Url: https://git.r.bdr.sh/rbdr/mobius/commitdiff_plain/6988a0571d5d37ea0f38ee3e4066533158f608bc Initial squashed commit --- 6988a0571d5d37ea0f38ee3e4066533158f608bc 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"