]> git.r.bdr.sh - rbdr/mobius/commitdiff
Initial squashed commit
authorJeff Halter <redacted>
Sun, 25 Jul 2021 00:54:17 +0000 (17:54 -0700)
committerJeff Halter <redacted>
Sun, 25 Jul 2021 00:54:17 +0000 (17:54 -0700)
75 files changed:
.circleci/config.yml [new file with mode: 0644]
.gitignore [new file with mode: 0644]
.run/go run server.go.run.xml [new file with mode: 0644]
Dockerfile [new file with mode: 0644]
LICENSE.txt [new file with mode: 0644]
Makefile [new file with mode: 0644]
README.md [new file with mode: 0644]
access.go [new file with mode: 0644]
account.go [new file with mode: 0644]
client.go [new file with mode: 0644]
client/banners/1.txt [new file with mode: 0644]
client/banners/2.txt [new file with mode: 0644]
client/banners/3.txt [new file with mode: 0644]
client/banners/4.txt [new file with mode: 0644]
client/banners/5.txt [new file with mode: 0644]
client/banners/6.txt [new file with mode: 0644]
client/banners/7.txt [new file with mode: 0644]
client/banners/8.txt [new file with mode: 0644]
client/banners/9.txt [new file with mode: 0644]
client/main.go [new file with mode: 0644]
client/mobius-client-config.yaml [new file with mode: 0644]
client_conn.go [new file with mode: 0644]
client_conn_test.go [new file with mode: 0644]
concat/slices.go [new file with mode: 0644]
config.go [new file with mode: 0644]
docs/Hotline login sequence.md [new file with mode: 0644]
field.go [new file with mode: 0644]
field_test.go [new file with mode: 0644]
file_header.go [new file with mode: 0644]
file_header_test.go [new file with mode: 0644]
file_path.go [new file with mode: 0644]
file_transfer.go [new file with mode: 0644]
files.go [new file with mode: 0644]
files_test.go [new file with mode: 0644]
flattened_file_object.go [new file with mode: 0644]
flattened_file_object_test.go [new file with mode: 0644]
go.mod [new file with mode: 0644]
go.sum [new file with mode: 0644]
news.go [new file with mode: 0644]
server.go [new file with mode: 0644]
server/main.go [new file with mode: 0644]
server/mobius/config/Agreement.txt [new file with mode: 0644]
server/mobius/config/MessageBoard.txt [new file with mode: 0644]
server/mobius/config/ThreadedNews.yaml [new file with mode: 0644]
server/mobius/config/Users/admin.yaml [new file with mode: 0644]
server/mobius/config/Users/guest.yaml [new file with mode: 0644]
server/mobius/config/config.yaml [new file with mode: 0644]
server_blackbox_test.go [new file with mode: 0644]
server_test.go [new file with mode: 0644]
stats.go [new file with mode: 0644]
test/.DS_Store [new file with mode: 0644]
test/config/.DS_Store [new file with mode: 0644]
test/config/Agreement.txt [new file with mode: 0644]
test/config/Files/test/testfile-1k [new file with mode: 0644]
test/config/Files/test/testfile-5k [new file with mode: 0644]
test/config/Files/testdir/some-nested-file.txt [new file with mode: 0644]
test/config/Files/testfile.sit [new file with mode: 0644]
test/config/Files/testfile.txt [new file with mode: 0644]
test/config/MessageBoard.txt [new file with mode: 0644]
test/config/ThreadedNews.yaml [new file with mode: 0644]
test/config/Users/.DS_Store [new file with mode: 0644]
test/config/Users/admin.yaml [new file with mode: 0644]
test/config/Users/guest.yaml [new file with mode: 0644]
test/config/config.yaml [new file with mode: 0644]
tracker.go [new file with mode: 0644]
tracker_test.go [new file with mode: 0644]
transaction.go [new file with mode: 0644]
transaction_handlers.go [new file with mode: 0644]
transaction_handlers_test.go [new file with mode: 0644]
transaction_test.go [new file with mode: 0644]
transfer.go [new file with mode: 0644]
transfer_test.go [new file with mode: 0644]
user.go [new file with mode: 0644]
user_test.go [new file with mode: 0644]
version.go [new file with mode: 0644]

diff --git a/.circleci/config.yml b/.circleci/config.yml
new file mode 100644 (file)
index 0000000..4ac6313
--- /dev/null
@@ -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 (file)
index 0000000..903ecb9
--- /dev/null
@@ -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 (file)
index 0000000..efbc814
--- /dev/null
@@ -0,0 +1,12 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="go run server.go" type="GoApplicationRunConfiguration" factoryName="Go Application">
+    <working_directory value="$PROJECT_DIR$/server" />
+    <go_parameters value="-race" />
+    <parameters value="--bind 5600" />
+    <kind value="FILE" />
+    <package value="bitbucket.org/jhalter/hotline" />
+    <directory value="$PROJECT_DIR$" />
+    <filePath value="$PROJECT_DIR$/server/main.go" />
+    <method v="2" />
+  </configuration>
+</component>
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644 (file)
index 0000000..156de2d
--- /dev/null
@@ -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 (file)
index 0000000..392591b
--- /dev/null
@@ -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 (file)
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 (file)
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 (file)
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 (file)
index 0000000..b0e9ee5
--- /dev/null
@@ -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 (file)
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 (file)
index 0000000..608a316
--- /dev/null
@@ -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 (file)
index 0000000..1121e06
--- /dev/null
@@ -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 (file)
index 0000000..608a316
--- /dev/null
@@ -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 (file)
index 0000000..3b9d720
--- /dev/null
@@ -0,0 +1,7 @@
+.___.__  ._______  _____._.___    .___ .______  ._______
+:   |  \ : .___  \ \__ _:||   |   : __|:      \ : .____/
+|   :   || :   |  |  |  :||   |   | : ||       || : _/\ 
+|   .   ||     :  |  |   ||   |/\ |   ||   |   ||   /  \
+|___|   | \_. ___/   |   ||   /  \|   ||___|   ||_.: __/
+    |___|   :/       |___||______/|___|    |___|   :/   
+            :                                           
diff --git a/client/banners/5.txt b/client/banners/5.txt
new file mode 100644 (file)
index 0000000..1d468d5
--- /dev/null
@@ -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 (file)
index 0000000..3098da2
--- /dev/null
@@ -0,0 +1,12 @@
+                                                                
+@@@  @@@   @@@@@@   @@@@@@@  @@@       @@@  @@@  @@@  @@@@@@@@  
+@@@  @@@  @@@@@@@@  @@@@@@@  @@@       @@@  @@@@ @@@  @@@@@@@@  
+@@!  @@@  @@!  @@@    @@!    @@!       @@!  @@!@!@@@  @@!       
+!@!  @!@  !@!  @!@    !@!    !@!       !@!  !@!!@!@!  !@!       
+@!@!@!@!  @!@  !@!    @!!    @!!       !!@  @!@ !!@!  @!!!:!    
+!!!@!!!!  !@!  !!!    !!!    !!!       !!!  !@!  !!!  !!!!!:    
+!!:  !!!  !!:  !!!    !!:    !!:       !!:  !!:  !!!  !!:       
+:!:  !:!  :!:  !:!    :!:     :!:      :!:  :!:  !:!  :!:       
+::   :::  ::::: ::     ::     :: ::::   ::   ::   ::   :: ::::  
+ :   : :   : :  :      :     : :: : :  :    ::    :   : :: ::   
+
diff --git a/client/banners/7.txt b/client/banners/7.txt
new file mode 100644 (file)
index 0000000..c072e25
--- /dev/null
@@ -0,0 +1,7 @@
+██╗  ██╗ ██████╗ ████████╗██╗     ██╗███╗   ██╗███████╗
+██║  ██║██╔═══██╗╚══██╔══╝██║     ██║████╗  ██║██╔════╝
+███████║██║   ██║   ██║   ██║     ██║██╔██╗ ██║█████╗  
+██╔══██║██║   ██║   ██║   ██║     ██║██║╚██╗██║██╔══╝  
+██║  ██║╚██████╔╝   ██║   ███████╗██║██║ ╚████║███████╗
+╚═╝  ╚═╝ ╚═════╝    ╚═╝   ╚══════╝╚═╝╚═╝  ╚═══╝╚══════╝
+
diff --git a/client/banners/8.txt b/client/banners/8.txt
new file mode 100644 (file)
index 0000000..d75e153
--- /dev/null
@@ -0,0 +1,10 @@
+ ██░ ██  ▒█████  ▄▄▄█████▓ ██▓     ██▓ ███▄    █ ▓█████ 
+▓██░ ██▒▒██▒  ██▒▓  ██▒ ▓▒▓██▒    ▓██▒ ██ ▀█   █ ▓█   ▀ 
+▒██▀▀██░▒██░  ██▒▒ ▓██░ ▒░▒██░    ▒██▒▓██  ▀█ ██▒▒███   
+░▓█ ░██ ▒██   ██░░ ▓██▓ ░ ▒██░    ░██░▓██▒  ▐▌██▒▒▓█  ▄ 
+░▓█▒░██▓░ ████▓▒░  ▒██▒ ░ ░██████▒░██░▒██░   ▓██░░▒████▒
+ ▒ ░░▒░▒░ ▒░▒░▒░   ▒ ░░   ░ ▒░▓  ░░▓  ░ ▒░   ▒ ▒ ░░ ▒░ ░
+ ▒ ░▒░ ░  ░ ▒ ▒░     ░    ░ ░ ▒  ░ ▒ ░░ ░░   ░ ▒░ ░ ░  ░
+ ░  ░░ ░░ ░ ░ ▒    ░        ░ ░    ▒ ░   ░   ░ ░    ░   
+ ░  ░  ░    ░ ░               ░  ░ ░           ░    ░  ░
+
diff --git a/client/banners/9.txt b/client/banners/9.txt
new file mode 100644 (file)
index 0000000..eba9134
--- /dev/null
@@ -0,0 +1,9 @@
+ █████   █████           █████    ████   ███                     
+░░███   ░░███           ░░███    ░░███  ░░░                      
+ ░███    ░███   ██████  ███████   ░███  ████  ████████    ██████ 
+ ░███████████  ███░░███░░░███░    ░███ ░░███ ░░███░░███  ███░░███
+ ░███░░░░░███ ░███ ░███  ░███     ░███  ░███  ░███ ░███ ░███████ 
+ ░███    ░███ ░███ ░███  ░███ ███ ░███  ░███  ░███ ░███ ░███░░░  
+ █████   █████░░██████   ░░█████  █████ █████ ████ █████░░██████ 
+░░░░░   ░░░░░  ░░░░░░     ░░░░░  ░░░░░ ░░░░░ ░░░░ ░░░░░  ░░░░░░  
+
diff --git a/client/main.go b/client/main.go
new file mode 100644 (file)
index 0000000..a761404
--- /dev/null
@@ -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 (file)
index 0000000..c46d269
--- /dev/null
@@ -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 (file)
index 0000000..5c25f92
--- /dev/null
@@ -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 (file)
index 0000000..d18f9b6
--- /dev/null
@@ -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 (file)
index 0000000..daf8aae
--- /dev/null
@@ -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 (file)
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 (file)
index 0000000..1ce0fba
--- /dev/null
@@ -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 (file)
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 (file)
index 0000000..fb686dc
--- /dev/null
@@ -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 (file)
index 0000000..60c652e
--- /dev/null
@@ -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 (file)
index 0000000..0194a60
--- /dev/null
@@ -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 (file)
index 0000000..71168dc
--- /dev/null
@@ -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 (file)
index 0000000..46b0061
--- /dev/null
@@ -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 (file)
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 (file)
index 0000000..88c893f
--- /dev/null
@@ -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 (file)
index 0000000..dfdd8a8
--- /dev/null
@@ -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 (file)
index 0000000..6a6953f
--- /dev/null
@@ -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 (file)
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 (file)
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 (file)
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 (file)
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 (file)
index 0000000..0b9a09c
--- /dev/null
@@ -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 (file)
index 0000000..5da4639
--- /dev/null
@@ -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 (file)
index 0000000..cb36354
--- /dev/null
@@ -0,0 +1 @@
+Welcome to Hotline
diff --git a/server/mobius/config/ThreadedNews.yaml b/server/mobius/config/ThreadedNews.yaml
new file mode 100644 (file)
index 0000000..dc2e8b5
--- /dev/null
@@ -0,0 +1 @@
+Categories:
diff --git a/server/mobius/config/Users/admin.yaml b/server/mobius/config/Users/admin.yaml
new file mode 100644 (file)
index 0000000..5413735
--- /dev/null
@@ -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 (file)
index 0000000..a1e4069
--- /dev/null
@@ -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 (file)
index 0000000..5c8b202
--- /dev/null
@@ -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 (file)
index 0000000..3718bd2
--- /dev/null
@@ -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 (file)
index 0000000..2e88202
--- /dev/null
@@ -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 (file)
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 (file)
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 (file)
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 (file)
index 0000000..2a3bdb7
--- /dev/null
@@ -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 (file)
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 (file)
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 (file)
index 0000000..e69de29
diff --git a/test/config/Files/testfile.sit b/test/config/Files/testfile.sit
new file mode 100644 (file)
index 0000000..8d1d542
--- /dev/null
@@ -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 (file)
index 0000000..f0607d4
--- /dev/null
@@ -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 (file)
index 0000000..1a2f57a
--- /dev/null
@@ -0,0 +1 @@
+From test (Dec31 15:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:43):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:43):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 15:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:2):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 14:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 13:59):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 13:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec31 10:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec08 14:39):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec08 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec08 7:59):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec08 7:59):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:43):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 11:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 10:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 10:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 10:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 10:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec07 9:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec05 17:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec03 10:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 17:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:17):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 15:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:56):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:17):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec02 14:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 13:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 13:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 13:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:16):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:3):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:3):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 12:3):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:45):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:45):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 11:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:45):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Dec01 10:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:28):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 14:19):\r\rTest News Post\r\r__________________________________________________________\rFrom  (Nov30 11:42):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:17):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:8):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:5):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:2):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:2):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 11:0):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:45):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 10:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:59):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov30 9:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:35):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:32):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:8):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:8):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:7):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 17:5):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:28):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 16:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 10:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov29 10:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 16:6):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 16:6):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:45):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:44):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:43):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 15:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:7):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:7):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:6):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:6):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:5):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:4):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:4):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:4):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:3):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:2):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:2):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:0):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 14:0):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:56):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:56):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:56):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:45):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 13:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:34):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:33):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:32):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:32):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:21):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:4):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 12:1):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:55):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:46):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:42):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:42):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:37):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:30):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:28):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:28):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:28):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:23):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:15):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:9):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:8):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:8):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:7):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 11:7):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:57):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:57):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Nov28 10:40):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:20):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:14):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 17:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:41):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:29):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:28):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:27):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:26):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:24):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:13):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:12):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:11):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:10):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 16:0):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:59):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:54):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:53):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:48):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:38):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 15:22):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:36):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:35):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:31):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:19):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 11:18):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:58):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:52):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul12 10:47):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul11 13:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 17:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 17:25):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:51):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:50):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:49):\r\rTest News Post\r\r__________________________________________________________\rFrom test (Jul01 9:45):\r\rTest News Post\r\r__________________________________________________________\r
\ No newline at end of file
diff --git a/test/config/ThreadedNews.yaml b/test/config/ThreadedNews.yaml
new file mode 100644 (file)
index 0000000..9f3fd65
--- /dev/null
@@ -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 (file)
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 (file)
index 0000000..1bf656b
--- /dev/null
@@ -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 (file)
index 0000000..57117bd
--- /dev/null
@@ -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 (file)
index 0000000..cb7f15a
--- /dev/null
@@ -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 (file)
index 0000000..97ca108
--- /dev/null
@@ -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 (file)
index 0000000..b904462
--- /dev/null
@@ -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 (file)
index 0000000..8fbdf35
--- /dev/null
@@ -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 (file)
index 0000000..7920eaa
--- /dev/null
@@ -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 (file)
index 0000000..3042da2
--- /dev/null
@@ -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 (file)
index 0000000..227b9e0
--- /dev/null
@@ -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 (file)
index 0000000..ecbe324
--- /dev/null
@@ -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 (file)
index 0000000..52044b0
--- /dev/null
@@ -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 (file)
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 (file)
index 0000000..dae4274
--- /dev/null
@@ -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 (file)
index 0000000..6335214
--- /dev/null
@@ -0,0 +1,3 @@
+package hotline
+
+const VERSION = "0.0.1"