]> git.r.bdr.sh - rbdr/mobius/blame - hotline/client.go
Align file size column
[rbdr/mobius] / hotline / client.go
CommitLineData
6988a057
JH
1package hotline
2
3import (
4 "bytes"
5 "embed"
6 "encoding/binary"
7 "errors"
8 "fmt"
6988a057
JH
9 "github.com/gdamore/tcell/v2"
10 "github.com/rivo/tview"
11 "github.com/stretchr/testify/mock"
12 "go.uber.org/zap"
13 "gopkg.in/yaml.v2"
6988a057
JH
14 "math/big"
15 "math/rand"
16 "net"
17 "os"
18 "strings"
19 "time"
20)
21
4f3c459c
JH
22const (
23 trackerListPage = "trackerList"
24)
6988a057 25
22c599ab 26//go:embed banners/*.txt
6988a057
JH
27var bannerDir embed.FS
28
29type Bookmark struct {
30 Name string `yaml:"Name"`
31 Addr string `yaml:"Addr"`
32 Login string `yaml:"Login"`
33 Password string `yaml:"Password"`
34}
35
36type ClientPrefs struct {
37 Username string `yaml:"Username"`
38 IconID int `yaml:"IconID"`
39 Bookmarks []Bookmark `yaml:"Bookmarks"`
4f3c459c 40 Tracker string `yaml:"Tracker"`
6988a057
JH
41}
42
f7e36225
JH
43func (cp *ClientPrefs) IconBytes() []byte {
44 iconBytes := make([]byte, 2)
45 binary.BigEndian.PutUint16(iconBytes, uint16(cp.IconID))
46 return iconBytes
47}
48
6988a057
JH
49func readConfig(cfgPath string) (*ClientPrefs, error) {
50 fh, err := os.Open(cfgPath)
51 if err != nil {
52 return nil, err
53 }
54
55 prefs := ClientPrefs{}
56 decoder := yaml.NewDecoder(fh)
57 decoder.SetStrict(true)
58 if err := decoder.Decode(&prefs); err != nil {
59 return nil, err
60 }
61 return &prefs, nil
62}
63
64type Client struct {
95753255 65 cfgPath string
6988a057
JH
66 DebugBuf *DebugBuffer
67 Connection net.Conn
6988a057
JH
68 Login *[]byte
69 Password *[]byte
6988a057
JH
70 Flags *[]byte
71 ID *[]byte
72 Version []byte
73 UserAccess []byte
43ecc0f4 74 filePath []string
6988a057
JH
75 UserList []User
76 Logger *zap.SugaredLogger
77 activeTasks map[uint32]*Transaction
78
79 pref *ClientPrefs
80
81 Handlers map[uint16]clientTHandler
82
83 UI *UI
84
85 outbox chan *Transaction
86 Inbox chan *Transaction
87}
88
b198b22b
JH
89func NewClient(cfgPath string, logger *zap.SugaredLogger) *Client {
90 c := &Client{
91 cfgPath: cfgPath,
92 Logger: logger,
93 activeTasks: make(map[uint32]*Transaction),
94 Handlers: clientHandlers,
6988a057 95 }
b198b22b 96 c.UI = NewUI(c)
6988a057 97
b198b22b 98 prefs, err := readConfig(cfgPath)
6988a057 99 if err != nil {
43ecc0f4
JH
100 fmt.Printf("unable to read config file %s", cfgPath)
101 os.Exit(1)
6988a057 102 }
b198b22b 103 c.pref = prefs
6988a057 104
b198b22b 105 return c
6988a057
JH
106}
107
6988a057
JH
108// DebugBuffer wraps a *tview.TextView and adds a Sync() method to make it available as a Zap logger
109type DebugBuffer struct {
110 TextView *tview.TextView
111}
112
113func (db *DebugBuffer) Write(p []byte) (int, error) {
114 return db.TextView.Write(p)
115}
116
117// Sync is a noop function that exists to satisfy the zapcore.WriteSyncer interface
118func (db *DebugBuffer) Sync() error {
119 return nil
120}
121
6988a057
JH
122func randomBanner() string {
123 rand.Seed(time.Now().UnixNano())
124
ce348eb8
JH
125 bannerFiles, _ := bannerDir.ReadDir("banners")
126 file, _ := bannerDir.ReadFile("banners/" + bannerFiles[rand.Intn(len(bannerFiles))].Name())
6988a057
JH
127
128 return fmt.Sprintf("\n\n\nWelcome to...\n\n[red::b]%s[-:-:-]\n\n", file)
129}
130
6988a057
JH
131type clientTransaction struct {
132 Name string
133 Handler func(*Client, *Transaction) ([]Transaction, error)
134}
135
136func (ch clientTransaction) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
137 return ch.Handler(cc, t)
138}
139
140type clientTHandler interface {
141 Handle(*Client, *Transaction) ([]Transaction, error)
142}
143
144type mockClientHandler struct {
145 mock.Mock
146}
147
148func (mh *mockClientHandler) Handle(cc *Client, t *Transaction) ([]Transaction, error) {
149 args := mh.Called(cc, t)
150 return args.Get(0).([]Transaction), args.Error(1)
151}
152
153var clientHandlers = map[uint16]clientTHandler{
154 // Server initiated
155 tranChatMsg: clientTransaction{
156 Name: "tranChatMsg",
157 Handler: handleClientChatMsg,
158 },
159 tranLogin: clientTransaction{
160 Name: "tranLogin",
161 Handler: handleClientTranLogin,
162 },
163 tranShowAgreement: clientTransaction{
164 Name: "tranShowAgreement",
165 Handler: handleClientTranShowAgreement,
166 },
167 tranUserAccess: clientTransaction{
168 Name: "tranUserAccess",
169 Handler: handleClientTranUserAccess,
170 },
171 tranGetUserNameList: clientTransaction{
172 Name: "tranGetUserNameList",
173 Handler: handleClientGetUserNameList,
174 },
175 tranNotifyChangeUser: clientTransaction{
176 Name: "tranNotifyChangeUser",
177 Handler: handleNotifyChangeUser,
178 },
179 tranNotifyDeleteUser: clientTransaction{
180 Name: "tranNotifyDeleteUser",
181 Handler: handleNotifyDeleteUser,
182 },
183 tranGetMsgs: clientTransaction{
184 Name: "tranNotifyDeleteUser",
185 Handler: handleGetMsgs,
186 },
43ecc0f4
JH
187 tranGetFileNameList: clientTransaction{
188 Name: "tranGetFileNameList",
189 Handler: handleGetFileNameList,
190 },
191}
192
193func handleGetFileNameList(c *Client, t *Transaction) (res []Transaction, err error) {
194 fTree := tview.NewTreeView().SetTopLevel(1)
195 root := tview.NewTreeNode("Root")
196 fTree.SetRoot(root).SetCurrentNode(root)
197 fTree.SetBorder(true).SetTitle("| Files |")
198 fTree.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
199 switch event.Key() {
200 case tcell.KeyEscape:
201 c.UI.Pages.RemovePage("files")
202 c.filePath = []string{}
203 case tcell.KeyEnter:
204 selectedNode := fTree.GetCurrentNode()
205
206 if selectedNode.GetText() == "<- Back" {
207 c.filePath = c.filePath[:len(c.filePath)-1]
208 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
209
210 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
211 c.UI.HLClient.Logger.Errorw("err", "err", err)
212 }
213 return event
214 }
215
216 entry := selectedNode.GetReference().(*FileNameWithInfo)
217
218 if bytes.Equal(entry.Type, []byte("fldr")) {
219 c.Logger.Infow("get new directory listing", "name", string(entry.Name))
220
221 c.filePath = append(c.filePath, string(entry.Name))
222 f := NewField(fieldFilePath, EncodeFilePath(strings.Join(c.filePath, "/")))
223
224 if err := c.UI.HLClient.Send(*NewTransaction(tranGetFileNameList, nil, f)); err != nil {
225 c.UI.HLClient.Logger.Errorw("err", "err", err)
226 }
227 } else {
228 // TODO: initiate file download
229 c.Logger.Infow("download file", "name", string(entry.Name))
230 }
231 }
232
233 return event
234 })
235
236 if len(c.filePath) > 0 {
237 node := tview.NewTreeNode("<- Back")
238 root.AddChild(node)
239 }
240
241 var fileList []FileNameWithInfo
242 for _, f := range t.Fields {
243 var fn FileNameWithInfo
244 _, _ = fn.Read(f.Data)
245 fileList = append(fileList, fn)
246
247 if bytes.Equal(fn.Type, []byte("fldr")) {
248 node := tview.NewTreeNode(fmt.Sprintf("[blue::]📁 %s[-:-:-]", fn.Name))
249 node.SetReference(&fn)
250 root.AddChild(node)
251 } else {
252 size := binary.BigEndian.Uint32(fn.FileSize) / 1024
253
246ed3a1 254 node := tview.NewTreeNode(fmt.Sprintf(" %-30s %15v KB", fn.Name, size))
43ecc0f4
JH
255 node.SetReference(&fn)
256 root.AddChild(node)
257 }
258
259 }
260
261 centerFlex := tview.NewFlex().
262 AddItem(nil, 0, 1, false).
263 AddItem(tview.NewFlex().
264 SetDirection(tview.FlexRow).
265 AddItem(nil, 0, 1, false).
266 AddItem(fTree, 20, 1, true).
246ed3a1 267 AddItem(nil, 0, 1, false), 60, 1, true).
43ecc0f4
JH
268 AddItem(nil, 0, 1, false)
269
270 c.UI.Pages.AddPage("files", centerFlex, true, true)
271 c.UI.App.Draw()
272
273 return res, err
6988a057
JH
274}
275
276func handleGetMsgs(c *Client, t *Transaction) (res []Transaction, err error) {
277 newsText := string(t.GetField(fieldData).Data)
278 newsText = strings.ReplaceAll(newsText, "\r", "\n")
279
280 newsTextView := tview.NewTextView().
281 SetText(newsText).
282 SetDoneFunc(func(key tcell.Key) {
283 c.UI.Pages.SwitchToPage("serverUI")
284 c.UI.App.SetFocus(c.UI.chatInput)
285 })
286 newsTextView.SetBorder(true).SetTitle("News")
287
288 c.UI.Pages.AddPage("news", newsTextView, true, true)
43ecc0f4
JH
289 //c.UI.Pages.SwitchToPage("news")
290 //c.UI.App.SetFocus(newsTextView)
6988a057
JH
291 c.UI.App.Draw()
292
293 return res, err
294}
295
296func handleNotifyChangeUser(c *Client, t *Transaction) (res []Transaction, err error) {
297 newUser := User{
298 ID: t.GetField(fieldUserID).Data,
299 Name: string(t.GetField(fieldUserName).Data),
300 Icon: t.GetField(fieldUserIconID).Data,
301 Flags: t.GetField(fieldUserFlags).Data,
302 }
303
304 // Possible cases:
305 // user is new to the server
306 // user is already on the server but has a new name
307
308 var oldName string
309 var newUserList []User
310 updatedUser := false
311 for _, u := range c.UserList {
312 c.Logger.Debugw("Comparing Users", "userToUpdate", newUser.ID, "myID", u.ID, "userToUpdateName", newUser.Name, "myname", u.Name)
313 if bytes.Equal(newUser.ID, u.ID) {
314 oldName = u.Name
315 u.Name = newUser.Name
316 if u.Name != newUser.Name {
317 _, _ = fmt.Fprintf(c.UI.chatBox, " <<< "+oldName+" is now known as "+newUser.Name+" >>>\n")
318 }
319 updatedUser = true
320 }
321 newUserList = append(newUserList, u)
322 }
323
324 if !updatedUser {
325 newUserList = append(newUserList, newUser)
326 }
327
328 c.UserList = newUserList
329
330 c.renderUserList()
331
332 return res, err
333}
334
335func handleNotifyDeleteUser(c *Client, t *Transaction) (res []Transaction, err error) {
336 exitUser := t.GetField(fieldUserID).Data
337
338 var newUserList []User
339 for _, u := range c.UserList {
340 if !bytes.Equal(exitUser, u.ID) {
341 newUserList = append(newUserList, u)
342 }
343 }
344
345 c.UserList = newUserList
346
347 c.renderUserList()
348
349 return res, err
350}
351
352const readBuffSize = 1024000 // 1KB - TODO: what should this be?
353
354func (c *Client) ReadLoop() error {
355 tranBuff := make([]byte, 0)
356 tReadlen := 0
357 // Infinite loop where take action on incoming client requests until the connection is closed
358 for {
359 buf := make([]byte, readBuffSize)
360 tranBuff = tranBuff[tReadlen:]
361
362 readLen, err := c.Connection.Read(buf)
363 if err != nil {
364 return err
365 }
366 tranBuff = append(tranBuff, buf[:readLen]...)
367
368 // We may have read multiple requests worth of bytes from Connection.Read. readTransactions splits them
369 // into a slice of transactions
370 var transactions []Transaction
371 if transactions, tReadlen, err = readTransactions(tranBuff); err != nil {
372 c.Logger.Errorw("Error handling transaction", "err", err)
373 }
374
375 // iterate over all of the transactions that were parsed from the byte slice and handle them
376 for _, t := range transactions {
377 if err := c.HandleTransaction(&t); err != nil {
378 c.Logger.Errorw("Error handling transaction", "err", err)
379 }
380 }
381 }
382}
383
384func (c *Client) GetTransactions() error {
385 tranBuff := make([]byte, 0)
386 tReadlen := 0
387
388 buf := make([]byte, readBuffSize)
389 tranBuff = tranBuff[tReadlen:]
390
391 readLen, err := c.Connection.Read(buf)
392 if err != nil {
393 return err
394 }
395 tranBuff = append(tranBuff, buf[:readLen]...)
396
397 return nil
398}
399
400func handleClientGetUserNameList(c *Client, t *Transaction) (res []Transaction, err error) {
401 var users []User
402 for _, field := range t.Fields {
71c56068
JH
403 // The Hotline protocol docs say that ClientGetUserNameList should only return fieldUsernameWithInfo (300)
404 // fields, but shxd sneaks in fieldChatSubject (115) so it's important to filter explicitly for the expected
405 // field type. Probably a good idea to do everywhere.
406 if bytes.Equal(field.ID, []byte{0x01, 0x2c}) {
407 u, err := ReadUser(field.Data)
408 if err != nil {
409 return res, err
410 }
411 users = append(users, *u)
412 }
6988a057
JH
413 }
414 c.UserList = users
415
416 c.renderUserList()
417
418 return res, err
419}
420
421func (c *Client) renderUserList() {
422 c.UI.userList.Clear()
423 for _, u := range c.UserList {
424 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(u.Flags)))
425 if flagBitmap.Bit(userFlagAdmin) == 1 {
5dd57308 426 _, _ = fmt.Fprintf(c.UI.userList, "[red::b]%s[-:-:-]\n", u.Name)
6988a057 427 } else {
5dd57308 428 _, _ = fmt.Fprintf(c.UI.userList, "%s\n", u.Name)
6988a057 429 }
b198b22b 430 // TODO: fade if user is away
6988a057
JH
431 }
432}
433
434func handleClientChatMsg(c *Client, t *Transaction) (res []Transaction, err error) {
5dd57308 435 _, _ = fmt.Fprintf(c.UI.chatBox, "%s \n", t.GetField(fieldData).Data)
6988a057
JH
436
437 return res, err
438}
439
440func handleClientTranUserAccess(c *Client, t *Transaction) (res []Transaction, err error) {
441 c.UserAccess = t.GetField(fieldUserAccess).Data
442
443 return res, err
444}
445
446func handleClientTranShowAgreement(c *Client, t *Transaction) (res []Transaction, err error) {
447 agreement := string(t.GetField(fieldData).Data)
448 agreement = strings.ReplaceAll(agreement, "\r", "\n")
449
450 c.UI.agreeModal = tview.NewModal().
451 SetText(agreement).
452 AddButtons([]string{"Agree", "Disagree"}).
453 SetDoneFunc(func(buttonIndex int, buttonLabel string) {
454 if buttonIndex == 0 {
455 res = append(res,
456 *NewTransaction(
457 tranAgreed, nil,
458 NewField(fieldUserName, []byte(c.pref.Username)),
f7e36225 459 NewField(fieldUserIconID, c.pref.IconBytes()),
6988a057
JH
460 NewField(fieldUserFlags, []byte{0x00, 0x00}),
461 NewField(fieldOptions, []byte{0x00, 0x00}),
462 ),
463 )
6988a057
JH
464 c.UI.Pages.HidePage("agreement")
465 c.UI.App.SetFocus(c.UI.chatInput)
466 } else {
f7e36225 467 _ = c.Disconnect()
6988a057
JH
468 c.UI.Pages.SwitchToPage("home")
469 }
470 },
471 )
472
473 c.Logger.Debug("show agreement page")
474 c.UI.Pages.AddPage("agreement", c.UI.agreeModal, false, true)
6988a057 475 c.UI.Pages.ShowPage("agreement ")
6988a057 476 c.UI.App.Draw()
b198b22b 477
6988a057
JH
478 return res, err
479}
480
481func handleClientTranLogin(c *Client, t *Transaction) (res []Transaction, err error) {
482 if !bytes.Equal(t.ErrorCode, []byte{0, 0, 0, 0}) {
483 errMsg := string(t.GetField(fieldError).Data)
484 errModal := tview.NewModal()
485 errModal.SetText(errMsg)
486 errModal.AddButtons([]string{"Oh no"})
487 errModal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
488 c.UI.Pages.RemovePage("errModal")
489 })
490 c.UI.Pages.RemovePage("joinServer")
491 c.UI.Pages.AddPage("errModal", errModal, false, true)
492
493 c.UI.App.Draw() // TODO: errModal doesn't render without this. wtf?
494
495 c.Logger.Error(string(t.GetField(fieldError).Data))
496 return nil, errors.New("login error: " + string(t.GetField(fieldError).Data))
497 }
498 c.UI.Pages.AddAndSwitchToPage("serverUI", c.UI.renderServerUI(), true)
499 c.UI.App.SetFocus(c.UI.chatInput)
500
501 if err := c.Send(*NewTransaction(tranGetUserNameList, nil)); err != nil {
502 c.Logger.Errorw("err", "err", err)
503 }
504 return res, err
505}
506
507// JoinServer connects to a Hotline server and completes the login flow
508func (c *Client) JoinServer(address, login, passwd string) error {
509 // Establish TCP connection to server
510 if err := c.connect(address); err != nil {
511 return err
512 }
513
514 // Send handshake sequence
515 if err := c.Handshake(); err != nil {
516 return err
517 }
518
519 // Authenticate (send tranLogin 107)
520 if err := c.LogIn(login, passwd); err != nil {
521 return err
522 }
523
524 return nil
525}
526
527// connect establishes a connection with a Server by sending handshake sequence
528func (c *Client) connect(address string) error {
529 var err error
530 c.Connection, err = net.DialTimeout("tcp", address, 5*time.Second)
531 if err != nil {
532 return err
533 }
534 return nil
535}
536
537var ClientHandshake = []byte{
538 0x54, 0x52, 0x54, 0x50, // TRTP
539 0x48, 0x4f, 0x54, 0x4c, // HOTL
540 0x00, 0x01,
541 0x00, 0x02,
542}
543
544var ServerHandshake = []byte{
545 0x54, 0x52, 0x54, 0x50, // TRTP
546 0x00, 0x00, 0x00, 0x00, // ErrorCode
547}
548
549func (c *Client) Handshake() error {
550 //Protocol ID 4 ‘TRTP’ 0x54 52 54 50
551 //Sub-protocol ID 4 User defined
552 //Version 2 1 Currently 1
553 //Sub-version 2 User defined
554 if _, err := c.Connection.Write(ClientHandshake); err != nil {
555 return fmt.Errorf("handshake write err: %s", err)
556 }
557
558 replyBuf := make([]byte, 8)
559 _, err := c.Connection.Read(replyBuf)
560 if err != nil {
561 return err
562 }
563
6988a057
JH
564 if bytes.Compare(replyBuf, ServerHandshake) == 0 {
565 return nil
566 }
6988a057 567
b198b22b 568 // In the case of an error, client and server close the connection.
6988a057
JH
569 return fmt.Errorf("handshake response err: %s", err)
570}
571
572func (c *Client) LogIn(login string, password string) error {
573 return c.Send(
574 *NewTransaction(
575 tranLogin, nil,
576 NewField(fieldUserName, []byte(c.pref.Username)),
f7e36225 577 NewField(fieldUserIconID, c.pref.IconBytes()),
6988a057
JH
578 NewField(fieldUserLogin, []byte(NegatedUserString([]byte(login)))),
579 NewField(fieldUserPassword, []byte(NegatedUserString([]byte(password)))),
580 NewField(fieldVersion, []byte{0, 2}),
581 ),
582 )
583}
584
6988a057
JH
585func (c *Client) Send(t Transaction) error {
586 requestNum := binary.BigEndian.Uint16(t.Type)
587 tID := binary.BigEndian.Uint32(t.ID)
588
589 //handler := TransactionHandlers[requestNum]
590
591 // if transaction is NOT reply, add it to the list to transactions we're expecting a response for
592 if t.IsReply == 0 {
593 c.activeTasks[tID] = &t
594 }
595
596 var n int
597 var err error
598 if n, err = c.Connection.Write(t.Payload()); err != nil {
599 return err
600 }
601 c.Logger.Debugw("Sent Transaction",
602 "IsReply", t.IsReply,
603 "type", requestNum,
604 "sentBytes", n,
605 )
606 return nil
607}
608
609func (c *Client) HandleTransaction(t *Transaction) error {
610 var origT Transaction
611 if t.IsReply == 1 {
612 requestID := binary.BigEndian.Uint32(t.ID)
613 origT = *c.activeTasks[requestID]
614 t.Type = origT.Type
615 }
616
617 requestNum := binary.BigEndian.Uint16(t.Type)
618 c.Logger.Infow(
619 "Received Transaction",
620 "RequestType", requestNum,
621 )
622
623 if handler, ok := c.Handlers[requestNum]; ok {
624 outT, _ := handler.Handle(c, t)
625 for _, t := range outT {
626 c.Send(t)
627 }
628 } else {
629 c.Logger.Errorw(
630 "Unimplemented transaction type received",
631 "RequestID", requestNum,
632 "TransactionID", t.ID,
633 )
634 }
635
636 return nil
637}
638
639func (c *Client) Connected() bool {
6988a057
JH
640 // c.Agreed == true &&
641 if c.UserAccess != nil {
642 return true
643 }
644 return false
645}
646
647func (c *Client) Disconnect() error {
648 err := c.Connection.Close()
649 if err != nil {
650 return err
651 }
652 return nil
653}