--- /dev/null
+# 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
--- /dev/null
+# 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
--- /dev/null
+<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
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+build-client:
+ go build -o mobius-hotline-client client/main.go
+
+build-server:
+ go build -o mobius-hotline-server server/main.go
--- /dev/null
+# Mobius
+
+Cross-platform command line [Hotline](https://en.wikipedia.org/wiki/Hotline_Communications) server and client
+
+[](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
--- /dev/null
+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
+}
--- /dev/null
+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,
+ )
+}
--- /dev/null
+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
+}
--- /dev/null
+ __ __ ______ ______ __ __ __ __ ______
+/\ \_\ \ /\ __ \ /\__ _\ /\ \ /\ \ /\ "-.\ \ /\ ___\
+\ \ __ \ \ \ \/\ \ \/_/\ \/ \ \ \____ \ \ \ \ \ \-. \ \ \ __\
+ \ \_\ \_\ \ \_____\ \ \_\ \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\
+ \/_/\/_/ \/_____/ \/_/ \/_____/ \/_/ \/_/ \/_/ \/_____/
+
\ No newline at end of file
--- /dev/null
+ ▄█ █▄ ▄██████▄ ███ ▄█ ▄█ ███▄▄▄▄ ▄████████
+ ███ ███ ███ ███ ▀█████████▄ ███ ███ ███▀▀▀██▄ ███ ███
+ ███ ███ ███ ███ ▀███▀▀██ ███ ███▌ ███ ███ ███ █▀
+ ▄███▄▄▄▄███▄▄ ███ ███ ███ ▀ ███ ███▌ ███ ███ ▄███▄▄▄
+▀▀███▀▀▀▀███▀ ███ ███ ███ ███ ███▌ ███ ███ ▀▀███▀▀▀
+ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ █▄
+ ███ ███ ███ ███ ███ ███▌ ▄ ███ ███ ███ ███ ███
+ ███ █▀ ▀██████▀ ▄████▀ █████▄▄██ █▀ ▀█ █▀ ██████████
+ ▀
\ No newline at end of file
--- /dev/null
+ __ __ ______ ______ __ __ __ __ ______
+/\ \_\ \ /\ __ \ /\__ _\ /\ \ /\ \ /\ "-.\ \ /\ ___\
+\ \ __ \ \ \ \/\ \ \/_/\ \/ \ \ \____ \ \ \ \ \ \-. \ \ \ __\
+ \ \_\ \_\ \ \_____\ \ \_\ \ \_____\ \ \_\ \ \_\\"\_\ \ \_____\
+ \/_/\/_/ \/_____/ \/_/ \/_____/ \/_/ \/_/ \/_/ \/_____/
+
\ No newline at end of file
--- /dev/null
+.___.__ ._______ _____._.___ .___ .______ ._______
+: | \ : .___ \ \__ _:|| | : __|: \ : .____/
+| : || : | | | :|| | | : || || : _/\
+| . || : | | || |/\ | || | || / \
+|___| | \_. ___/ | || / \| ||___| ||_.: __/
+ |___| :/ |___||______/|___| |___| :/
+ :
--- /dev/null
+ ,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
+
--- /dev/null
+
+@@@ @@@ @@@@@@ @@@@@@@ @@@ @@@ @@@ @@@ @@@@@@@@
+@@@ @@@ @@@@@@@@ @@@@@@@ @@@ @@@ @@@@ @@@ @@@@@@@@
+@@! @@@ @@! @@@ @@! @@! @@! @@!@!@@@ @@!
+!@! @!@ !@! @!@ !@! !@! !@! !@!!@!@! !@!
+@!@!@!@! @!@ !@! @!! @!! !!@ @!@ !!@! @!!!:!
+!!!@!!!! !@! !!! !!! !!! !!! !@! !!! !!!!!:
+!!: !!! !!: !!! !!: !!: !!: !!: !!! !!:
+:!: !:! :!: !:! :!: :!: :!: :!: !:! :!:
+:: ::: ::::: :: :: :: :::: :: :: :: :: ::::
+ : : : : : : : : :: : : : :: : : :: ::
+
--- /dev/null
+██╗ ██╗ ██████╗ ████████╗██╗ ██╗███╗ ██╗███████╗
+██║ ██║██╔═══██╗╚══██╔══╝██║ ██║████╗ ██║██╔════╝
+███████║██║ ██║ ██║ ██║ ██║██╔██╗ ██║█████╗
+██╔══██║██║ ██║ ██║ ██║ ██║██║╚██╗██║██╔══╝
+██║ ██║╚██████╔╝ ██║ ███████╗██║██║ ╚████║███████╗
+╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝
+
--- /dev/null
+ ██░ ██ ▒█████ ▄▄▄█████▓ ██▓ ██▓ ███▄ █ ▓█████
+▓██░ ██▒▒██▒ ██▒▓ ██▒ ▓▒▓██▒ ▓██▒ ██ ▀█ █ ▓█ ▀
+▒██▀▀██░▒██░ ██▒▒ ▓██░ ▒░▒██░ ▒██▒▓██ ▀█ ██▒▒███
+░▓█ ░██ ▒██ ██░░ ▓██▓ ░ ▒██░ ░██░▓██▒ ▐▌██▒▒▓█ ▄
+░▓█▒░██▓░ ████▓▒░ ▒██▒ ░ ░██████▒░██░▒██░ ▓██░░▒████▒
+ ▒ ░░▒░▒░ ▒░▒░▒░ ▒ ░░ ░ ▒░▓ ░░▓ ░ ▒░ ▒ ▒ ░░ ▒░ ░
+ ▒ ░▒░ ░ ░ ▒ ▒░ ░ ░ ░ ▒ ░ ▒ ░░ ░░ ░ ▒░ ░ ░ ░
+ ░ ░░ ░░ ░ ░ ▒ ░ ░ ░ ▒ ░ ░ ░ ░ ░
+ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
+
--- /dev/null
+ █████ █████ █████ ████ ███
+░░███ ░░███ ░░███ ░░███ ░░░
+ ░███ ░███ ██████ ███████ ░███ ████ ████████ ██████
+ ░███████████ ███░░███░░░███░ ░███ ░░███ ░░███░░███ ███░░███
+ ░███░░░░░███ ░███ ░███ ░███ ░███ ░███ ░███ ░███ ░███████
+ ░███ ░███ ░███ ░███ ░███ ███ ░███ ░███ ░███ ░███ ░███░░░
+ █████ █████░░██████ ░░█████ █████ █████ ████ █████░░██████
+░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░ ░░░░░ ░░░░ ░░░░░ ░░░░░░
+
--- /dev/null
+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,
+}
--- /dev/null
+Username: unnamed
+IconID: 2000
+Bookmarks:
+ - Name: Example Server
+ Addr: localhost:5500
+ Login: guest
+ Password: ""
--- /dev/null
+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)),
+ },
+ }
+}
--- /dev/null
+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
--- /dev/null
+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
+}
+
--- /dev/null
+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
+}
--- /dev/null
+## 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
+
+```
+
+```
--- /dev/null
+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),
+ )
+
+}
--- /dev/null
+package hotline
+
+import "testing"
+
+func TestHello(t *testing.T) {
+
+}
--- /dev/null
+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,
+ )
+}
--- /dev/null
+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)
+ }
+ })
+ }
+}
--- /dev/null
+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)
+}
--- /dev/null
+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
+}
--- /dev/null
+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()
+}
--- /dev/null
+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)
+ }
+ })
+ }
+}
--- /dev/null
+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
+}
--- /dev/null
+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)
+// }
+//}
--- /dev/null
+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
+)
--- /dev/null
+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=
--- /dev/null
+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
+}
--- /dev/null
+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
+}
--- /dev/null
+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,
+}
--- /dev/null
+This is an agreement. Say you agree.
--- /dev/null
+Welcome to Hotline
--- /dev/null
+Categories:
--- /dev/null
+Login: admin
+Name: admin
+Password: $2a$04$2itGEYx8C1N5bsfRSoC9JuonS3I4YfnyVPZHLSwp7kEInRX0yoB.a
+Access:
+- 255
+- 255
+- 255
+- 255
+- 255
+- 255
+- 255
+- 255
--- /dev/null
+Login: guest
+Name: guest
+Password: $2a$04$9P/jgLn1fR9TjSoWL.rKxuN6g.1TSpf2o6Hw.aaRuBwrWIJNwsKkS
+Access:
+- 252
+- 240
+- 205
+- 201
+- 43
+- 128
+- 0
+- 0
--- /dev/null
+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
--- /dev/null
+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)
+//}
--- /dev/null
+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)
+// }
+// })
+// }
+//}
--- /dev/null
+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,
+ )
+}
--- /dev/null
+This is a server agreement. Say you agree.
\ No newline at end of file
--- /dev/null
+nothing to see here
\ No newline at end of file
--- /dev/null
+Hello, I'm a test file!
\ No newline at end of file
--- /dev/null
+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
--- /dev/null
+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: []
--- /dev/null
+Login: admin
+Name: admin
+Password: $2a$04$2itGEYx8C1N5bsfRSoC9JuonS3I4YfnyVPZHLSwp7kEInRX0yoB.a
+Access:
+- 255
+- 255
+- 255
+- 255
+- 255
+- 255
+- 0
+- 0
+
--- /dev/null
+Login: guest
+Name: guest
+Password: $2a$04$9P/jgLn1fR9TjSoWL.rKxuN6g.1TSpf2o6Hw.aaRuBwrWIJNwsKkS
+Access:
+- 125
+- 240
+- 12
+- 239
+- 171
+- 128
+- 0
+- 0
--- /dev/null
+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
--- /dev/null
+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))),
+ )
+}
--- /dev/null
+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)
+ }
+ })
+ }
+}
--- /dev/null
+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{}
+}
--- /dev/null
+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
+}
--- /dev/null
+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)
+ }
+ })
+ }
+}
--- /dev/null
+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)
+ }
+ })
+ }
+}
--- /dev/null
+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
+//}
--- /dev/null
+package hotline
+
+import "testing"
+
+func TestReadTransfer(t *testing.T) {
+
+}
--- /dev/null
+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
+}
--- /dev/null
+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)
+ }
+ })
+ }
+}
--- /dev/null
+package hotline
+
+const VERSION = "0.0.1"