8 "github.com/davecgh/go-spew/spew"
20 // HandlerFunc is the signature of a func to handle a Hotline transaction.
21 type HandlerFunc func(*ClientConn, *Transaction) []Transaction
23 // TransactionHandlers maps a transaction type to a handler function.
24 var TransactionHandlers = map[TranType]HandlerFunc{
25 TranAgreed: HandleTranAgreed,
26 TranChatSend: HandleChatSend,
27 TranDelNewsArt: HandleDelNewsArt,
28 TranDelNewsItem: HandleDelNewsItem,
29 TranDeleteFile: HandleDeleteFile,
30 TranDeleteUser: HandleDeleteUser,
31 TranDisconnectUser: HandleDisconnectUser,
32 TranDownloadFile: HandleDownloadFile,
33 TranDownloadFldr: HandleDownloadFolder,
34 TranGetClientInfoText: HandleGetClientInfoText,
35 TranGetFileInfo: HandleGetFileInfo,
36 TranGetFileNameList: HandleGetFileNameList,
37 TranGetMsgs: HandleGetMsgs,
38 TranGetNewsArtData: HandleGetNewsArtData,
39 TranGetNewsArtNameList: HandleGetNewsArtNameList,
40 TranGetNewsCatNameList: HandleGetNewsCatNameList,
41 TranGetUser: HandleGetUser,
42 TranGetUserNameList: HandleGetUserNameList,
43 TranInviteNewChat: HandleInviteNewChat,
44 TranInviteToChat: HandleInviteToChat,
45 TranJoinChat: HandleJoinChat,
46 TranKeepAlive: HandleKeepAlive,
47 TranLeaveChat: HandleLeaveChat,
48 TranListUsers: HandleListUsers,
49 TranMoveFile: HandleMoveFile,
50 TranNewFolder: HandleNewFolder,
51 TranNewNewsCat: HandleNewNewsCat,
52 TranNewNewsFldr: HandleNewNewsFldr,
53 TranNewUser: HandleNewUser,
54 TranUpdateUser: HandleUpdateUser,
55 TranOldPostNews: HandleTranOldPostNews,
56 TranPostNewsArt: HandlePostNewsArt,
57 TranRejectChatInvite: HandleRejectChatInvite,
58 TranSendInstantMsg: HandleSendInstantMsg,
59 TranSetChatSubject: HandleSetChatSubject,
60 TranMakeFileAlias: HandleMakeAlias,
61 TranSetClientUserInfo: HandleSetClientUserInfo,
62 TranSetFileInfo: HandleSetFileInfo,
63 TranSetUser: HandleSetUser,
64 TranUploadFile: HandleUploadFile,
65 TranUploadFldr: HandleUploadFolder,
66 TranUserBroadcast: HandleUserBroadcast,
67 TranDownloadBanner: HandleDownloadBanner,
70 // The total size of a chat message data field is 8192 bytes.
71 const chatMsgLimit = 8192
73 func HandleChatSend(cc *ClientConn, t *Transaction) (res []Transaction) {
74 if !cc.Authorize(accessSendChat) {
75 return cc.NewErrReply(t, "You are not allowed to participate in chat.")
78 // Truncate long usernames
79 // %13.13s: This means a string that is right-aligned in a field of 13 characters.
80 // If the string is longer than 13 characters, it will be truncated to 13 characters.
81 formattedMsg := fmt.Sprintf("\r%13.13s: %s", cc.UserName, t.GetField(FieldData).Data)
83 // By holding the option key, Hotline chat allows users to send /me formatted messages like:
84 // *** Halcyon does stuff
85 // This is indicated by the presence of the optional field FieldChatOptions set to a value of 1.
86 // Most clients do not send this option for normal chat messages.
87 if t.GetField(FieldChatOptions).Data != nil && bytes.Equal(t.GetField(FieldChatOptions).Data, []byte{0, 1}) {
88 formattedMsg = fmt.Sprintf("\r*** %s %s", cc.UserName, t.GetField(FieldData).Data)
91 // Truncate the message to the limit. This does not handle the edge case of a string ending on multibyte character.
92 formattedMsg = formattedMsg[:min(len(formattedMsg), chatMsgLimit)]
94 // The ChatID field is used to identify messages as belonging to a private chat.
95 // All clients *except* Frogblast omit this field for public chat, but Frogblast sends a value of 00 00 00 00.
96 chatID := t.GetField(FieldChatID).Data
97 if chatID != nil && !bytes.Equal([]byte{0, 0, 0, 0}, chatID) {
98 privChat := cc.Server.PrivateChats[[4]byte(chatID)]
100 // send the message to all connected clients of the private chat
101 for _, c := range privChat.ClientConn {
102 res = append(res, NewTransaction(
105 NewField(FieldChatID, chatID),
106 NewField(FieldData, []byte(formattedMsg)),
112 //cc.Server.mux.Lock()
113 for _, c := range cc.Server.Clients {
114 if c == nil || cc.Account == nil {
117 // Skip clients that do not have the read chat permission.
118 if c.Authorize(accessReadChat) {
119 res = append(res, NewTransaction(TranChatMsg, c.ID, NewField(FieldData, []byte(formattedMsg))))
122 //cc.Server.mux.Unlock()
127 // HandleSendInstantMsg sends instant message to the user on the current server.
128 // Fields used in the request:
132 // One of the following values:
133 // - User message (myOpt_UserMessage = 1)
134 // - Refuse message (myOpt_RefuseMessage = 2)
135 // - Refuse chat (myOpt_RefuseChat = 3)
136 // - Automatic response (myOpt_AutomaticResponse = 4)"
138 // 214 Quoting message Optional
140 // Fields used in the reply:
142 func HandleSendInstantMsg(cc *ClientConn, t *Transaction) (res []Transaction) {
143 if !cc.Authorize(accessSendPrivMsg) {
144 return cc.NewErrReply(t, "You are not allowed to send private messages.")
147 msg := t.GetField(FieldData)
148 userID := t.GetField(FieldUserID)
150 reply := NewTransaction(
152 [2]byte(userID.Data),
153 NewField(FieldData, msg.Data),
154 NewField(FieldUserName, cc.UserName),
155 NewField(FieldUserID, cc.ID[:]),
156 NewField(FieldOptions, []byte{0, 1}),
159 // Later versions of Hotline include the original message in the FieldQuotingMsg field so
160 // the receiving client can display both the received message and what it is in reply to
161 if t.GetField(FieldQuotingMsg).Data != nil {
162 reply.Fields = append(reply.Fields, NewField(FieldQuotingMsg, t.GetField(FieldQuotingMsg).Data))
165 otherClient, ok := cc.Server.Clients[[2]byte(userID.Data)]
170 // Check if target user has "Refuse private messages" flag
171 if otherClient.Flags.IsSet(UserFlagRefusePM) {
176 NewField(FieldData, []byte(string(otherClient.UserName)+" does not accept private messages.")),
177 NewField(FieldUserName, otherClient.UserName),
178 NewField(FieldUserID, otherClient.ID[:]),
179 NewField(FieldOptions, []byte{0, 2}),
183 res = append(res, reply)
186 // Respond with auto reply if other client has it enabled
187 if len(otherClient.AutoReply) > 0 {
192 NewField(FieldData, otherClient.AutoReply),
193 NewField(FieldUserName, otherClient.UserName),
194 NewField(FieldUserID, otherClient.ID[:]),
195 NewField(FieldOptions, []byte{0, 1}),
200 return append(res, cc.NewReply(t))
203 var fileTypeFLDR = [4]byte{0x66, 0x6c, 0x64, 0x72}
205 func HandleGetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction) {
206 fileName := t.GetField(FieldFileName).Data
207 filePath := t.GetField(FieldFilePath).Data
209 fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
214 fw, err := newFileWrapper(cc.Server.FS, fullFilePath, 0)
219 encodedName, err := txtEncoder.String(fw.name)
225 NewField(FieldFileName, []byte(encodedName)),
226 NewField(FieldFileTypeString, fw.ffo.FlatFileInformationFork.friendlyType()),
227 NewField(FieldFileCreatorString, fw.ffo.FlatFileInformationFork.friendlyCreator()),
228 NewField(FieldFileType, fw.ffo.FlatFileInformationFork.TypeSignature[:]),
229 NewField(FieldFileCreateDate, fw.ffo.FlatFileInformationFork.CreateDate[:]),
230 NewField(FieldFileModifyDate, fw.ffo.FlatFileInformationFork.ModifyDate[:]),
233 // Include the optional FileComment field if there is a comment.
234 if len(fw.ffo.FlatFileInformationFork.Comment) != 0 {
235 fields = append(fields, NewField(FieldFileComment, fw.ffo.FlatFileInformationFork.Comment))
238 // Include the FileSize field for files.
239 if fw.ffo.FlatFileInformationFork.TypeSignature != fileTypeFLDR {
240 fields = append(fields, NewField(FieldFileSize, fw.totalSize()))
243 res = append(res, cc.NewReply(t, fields...))
247 // HandleSetFileInfo updates a file or folder Name and/or comment from the Get Info window
248 // Fields used in the request:
250 // * 202 File path Optional
251 // * 211 File new Name Optional
252 // * 210 File comment Optional
253 // Fields used in the reply: None
254 func HandleSetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction) {
255 fileName := t.GetField(FieldFileName).Data
256 filePath := t.GetField(FieldFilePath).Data
258 fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
263 fi, err := cc.Server.FS.Stat(fullFilePath)
268 hlFile, err := newFileWrapper(cc.Server.FS, fullFilePath, 0)
272 if t.GetField(FieldFileComment).Data != nil {
273 switch mode := fi.Mode(); {
275 if !cc.Authorize(accessSetFolderComment) {
276 return cc.NewErrReply(t, "You are not allowed to set comments for folders.")
278 case mode.IsRegular():
279 if !cc.Authorize(accessSetFileComment) {
280 return cc.NewErrReply(t, "You are not allowed to set comments for files.")
284 if err := hlFile.ffo.FlatFileInformationFork.setComment(t.GetField(FieldFileComment).Data); err != nil {
287 w, err := hlFile.infoForkWriter()
291 _, err = io.Copy(w, &hlFile.ffo.FlatFileInformationFork)
297 fullNewFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, t.GetField(FieldFileNewName).Data)
302 fileNewName := t.GetField(FieldFileNewName).Data
304 if fileNewName != nil {
305 switch mode := fi.Mode(); {
307 if !cc.Authorize(accessRenameFolder) {
308 return cc.NewErrReply(t, "You are not allowed to rename folders.")
310 err = os.Rename(fullFilePath, fullNewFilePath)
311 if os.IsNotExist(err) {
312 return cc.NewErrReply(t, "Cannot rename folder "+string(fileName)+" because it does not exist or cannot be found.")
315 case mode.IsRegular():
316 if !cc.Authorize(accessRenameFile) {
317 return cc.NewErrReply(t, "You are not allowed to rename files.")
319 fileDir, err := readPath(cc.Server.Config.FileRoot, filePath, []byte{})
323 hlFile.name, err = txtDecoder.String(string(fileNewName))
328 err = hlFile.move(fileDir)
329 if os.IsNotExist(err) {
330 return cc.NewErrReply(t, "Cannot rename file "+string(fileName)+" because it does not exist or cannot be found.")
338 res = append(res, cc.NewReply(t))
342 // HandleDeleteFile deletes a file or folder
343 // Fields used in the request:
346 // Fields used in the reply: none
347 func HandleDeleteFile(cc *ClientConn, t *Transaction) (res []Transaction) {
348 fileName := t.GetField(FieldFileName).Data
349 filePath := t.GetField(FieldFilePath).Data
351 fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
356 hlFile, err := newFileWrapper(cc.Server.FS, fullFilePath, 0)
361 fi, err := hlFile.dataFile()
363 return cc.NewErrReply(t, "Cannot delete file "+string(fileName)+" because it does not exist or cannot be found.")
366 switch mode := fi.Mode(); {
368 if !cc.Authorize(accessDeleteFolder) {
369 return cc.NewErrReply(t, "You are not allowed to delete folders.")
371 case mode.IsRegular():
372 if !cc.Authorize(accessDeleteFile) {
373 return cc.NewErrReply(t, "You are not allowed to delete files.")
377 if err := hlFile.delete(); err != nil {
381 res = append(res, cc.NewReply(t))
385 // HandleMoveFile moves files or folders. Note: seemingly not documented
386 func HandleMoveFile(cc *ClientConn, t *Transaction) (res []Transaction) {
387 fileName := string(t.GetField(FieldFileName).Data)
389 filePath, err := readPath(cc.Server.Config.FileRoot, t.GetField(FieldFilePath).Data, t.GetField(FieldFileName).Data)
394 fileNewPath, err := readPath(cc.Server.Config.FileRoot, t.GetField(FieldFileNewPath).Data, nil)
399 cc.logger.Info("Move file", "src", filePath+"/"+fileName, "dst", fileNewPath+"/"+fileName)
401 hlFile, err := newFileWrapper(cc.Server.FS, filePath, 0)
406 fi, err := hlFile.dataFile()
408 return cc.NewErrReply(t, "Cannot delete file "+fileName+" because it does not exist or cannot be found.")
410 switch mode := fi.Mode(); {
412 if !cc.Authorize(accessMoveFolder) {
413 return cc.NewErrReply(t, "You are not allowed to move folders.")
415 case mode.IsRegular():
416 if !cc.Authorize(accessMoveFile) {
417 return cc.NewErrReply(t, "You are not allowed to move files.")
420 if err := hlFile.move(fileNewPath); err != nil {
423 // TODO: handle other possible errors; e.g. fileWrapper delete fails due to fileWrapper permission issue
425 res = append(res, cc.NewReply(t))
429 func HandleNewFolder(cc *ClientConn, t *Transaction) (res []Transaction) {
430 if !cc.Authorize(accessCreateFolder) {
431 return cc.NewErrReply(t, "You are not allowed to create folders.")
433 folderName := string(t.GetField(FieldFileName).Data)
435 folderName = path.Join("/", folderName)
439 // FieldFilePath is only present for nested paths
440 if t.GetField(FieldFilePath).Data != nil {
442 _, err := newFp.Write(t.GetField(FieldFilePath).Data)
447 for _, pathItem := range newFp.Items {
448 subPath = filepath.Join("/", subPath, string(pathItem.Name))
451 newFolderPath := path.Join(cc.Server.Config.FileRoot, subPath, folderName)
452 newFolderPath, err := txtDecoder.String(newFolderPath)
457 // TODO: check path and folder Name lengths
459 if _, err := cc.Server.FS.Stat(newFolderPath); !os.IsNotExist(err) {
460 msg := fmt.Sprintf("Cannot create folder \"%s\" because there is already a file or folder with that Name.", folderName)
461 return cc.NewErrReply(t, msg)
464 if err := cc.Server.FS.Mkdir(newFolderPath, 0777); err != nil {
465 msg := fmt.Sprintf("Cannot create folder \"%s\" because an error occurred.", folderName)
466 return cc.NewErrReply(t, msg)
469 res = append(res, cc.NewReply(t))
473 func HandleSetUser(cc *ClientConn, t *Transaction) (res []Transaction) {
474 if !cc.Authorize(accessModifyUser) {
475 return cc.NewErrReply(t, "You are not allowed to modify accounts.")
478 login := string(encodeString(t.GetField(FieldUserLogin).Data))
479 userName := string(t.GetField(FieldUserName).Data)
481 newAccessLvl := t.GetField(FieldUserAccess).Data
483 account := cc.Server.Accounts[login]
485 return cc.NewErrReply(t, "Account not found.")
487 account.Name = userName
488 copy(account.Access[:], newAccessLvl)
490 // If the password field is cleared in the Hotline edit user UI, the SetUser transaction does
491 // not include FieldUserPassword
492 if t.GetField(FieldUserPassword).Data == nil {
493 account.Password = hashAndSalt([]byte(""))
496 if !bytes.Equal([]byte{0}, t.GetField(FieldUserPassword).Data) {
497 account.Password = hashAndSalt(t.GetField(FieldUserPassword).Data)
500 out, err := yaml.Marshal(&account)
504 if err := os.WriteFile(filepath.Join(cc.Server.ConfigDir, "Users", login+".yaml"), out, 0666); err != nil {
508 // Notify connected clients logged in as the user of the new access level
509 for _, c := range cc.Server.Clients {
510 if c.Account.Login == login {
511 newT := NewTransaction(TranUserAccess, c.ID, NewField(FieldUserAccess, newAccessLvl))
512 res = append(res, newT)
514 if c.Authorize(accessDisconUser) {
515 c.Flags.Set(UserFlagAdmin, 1)
517 c.Flags.Set(UserFlagAdmin, 0)
520 c.Account.Access = account.Access
523 TranNotifyChangeUser,
524 NewField(FieldUserID, c.ID[:]),
525 NewField(FieldUserFlags, c.Flags[:]),
526 NewField(FieldUserName, c.UserName),
527 NewField(FieldUserIconID, c.Icon),
532 res = append(res, cc.NewReply(t))
536 func HandleGetUser(cc *ClientConn, t *Transaction) (res []Transaction) {
537 if !cc.Authorize(accessOpenUser) {
538 return cc.NewErrReply(t, "You are not allowed to view accounts.")
541 account := cc.Server.Accounts[string(t.GetField(FieldUserLogin).Data)]
543 return cc.NewErrReply(t, "Account does not exist.")
546 res = append(res, cc.NewReply(t,
547 NewField(FieldUserName, []byte(account.Name)),
548 NewField(FieldUserLogin, encodeString(t.GetField(FieldUserLogin).Data)),
549 NewField(FieldUserPassword, []byte(account.Password)),
550 NewField(FieldUserAccess, account.Access[:]),
555 func HandleListUsers(cc *ClientConn, t *Transaction) (res []Transaction) {
556 if !cc.Authorize(accessOpenUser) {
557 return cc.NewErrReply(t, "You are not allowed to view accounts.")
560 var userFields []Field
561 for _, acc := range cc.Server.Accounts {
563 b, err := io.ReadAll(&accCopy)
568 userFields = append(userFields, NewField(FieldData, b))
571 res = append(res, cc.NewReply(t, userFields...))
575 // HandleUpdateUser is used by the v1.5+ multi-user editor to perform account editing for multiple users at a time.
576 // An update can be a mix of these actions:
579 // * Modify user (including renaming the account login)
581 // The Transaction sent by the client includes one data field per user that was modified. This data field in turn
582 // contains another data field encoded in its payload with a varying number of sub fields depending on which action is
583 // performed. This seems to be the only place in the Hotline protocol where a data field contains another data field.
584 func HandleUpdateUser(cc *ClientConn, t *Transaction) (res []Transaction) {
585 for _, field := range t.Fields {
586 var subFields []Field
588 // Create a new scanner for parsing incoming bytes into transaction tokens
589 scanner := bufio.NewScanner(bytes.NewReader(field.Data[2:]))
590 scanner.Split(fieldScanner)
592 for i := 0; i < int(binary.BigEndian.Uint16(field.Data[0:2])); i++ {
596 if _, err := field.Write(scanner.Bytes()); err != nil {
599 subFields = append(subFields, field)
602 // If there's only one subfield, that indicates this is a delete operation for the login in FieldData
603 if len(subFields) == 1 {
604 if !cc.Authorize(accessDeleteUser) {
605 return cc.NewErrReply(t, "You are not allowed to delete accounts.")
608 login := string(encodeString(getField(FieldData, &subFields).Data))
609 cc.logger.Info("DeleteUser", "login", login)
611 if err := cc.Server.DeleteUser(login); err != nil {
617 // login of the account to update
618 var accountToUpdate, loginToRename string
620 // If FieldData is included, this is a rename operation where FieldData contains the login of the existing
621 // account and FieldUserLogin contains the new login.
622 if getField(FieldData, &subFields) != nil {
623 loginToRename = string(encodeString(getField(FieldData, &subFields).Data))
625 userLogin := string(encodeString(getField(FieldUserLogin, &subFields).Data))
626 if loginToRename != "" {
627 accountToUpdate = loginToRename
629 accountToUpdate = userLogin
632 // Check if accountToUpdate has an existing account. If so, we know we are updating an existing user.
633 if acc, ok := cc.Server.Accounts[accountToUpdate]; ok {
634 if loginToRename != "" {
635 cc.logger.Info("RenameUser", "prevLogin", accountToUpdate, "newLogin", userLogin)
637 cc.logger.Info("UpdateUser", "login", accountToUpdate)
640 // account exists, so this is an update action
641 if !cc.Authorize(accessModifyUser) {
642 return cc.NewErrReply(t, "You are not allowed to modify accounts.")
645 // This part is a bit tricky. There are three possibilities:
646 // 1) The transaction is intended to update the password.
647 // In this case, FieldUserPassword is sent with the new password.
648 // 2) The transaction is intended to remove the password.
649 // In this case, FieldUserPassword is not sent.
650 // 3) The transaction updates the users access bits, but not the password.
651 // In this case, FieldUserPassword is sent with zero as the only byte.
652 if getField(FieldUserPassword, &subFields) != nil {
653 newPass := getField(FieldUserPassword, &subFields).Data
654 if !bytes.Equal([]byte{0}, newPass) {
655 acc.Password = hashAndSalt(newPass)
658 acc.Password = hashAndSalt([]byte(""))
661 if getField(FieldUserAccess, &subFields) != nil {
662 copy(acc.Access[:], getField(FieldUserAccess, &subFields).Data)
665 err := cc.Server.UpdateUser(
666 string(encodeString(getField(FieldData, &subFields).Data)),
667 string(encodeString(getField(FieldUserLogin, &subFields).Data)),
668 string(getField(FieldUserName, &subFields).Data),
676 if !cc.Authorize(accessCreateUser) {
677 return cc.NewErrReply(t, "You are not allowed to create new accounts.")
680 cc.logger.Info("CreateUser", "login", userLogin)
682 newAccess := accessBitmap{}
683 copy(newAccess[:], getField(FieldUserAccess, &subFields).Data)
685 // Prevent account from creating new account with greater permission
686 for i := 0; i < 64; i++ {
687 if newAccess.IsSet(i) {
688 if !cc.Authorize(i) {
689 return cc.NewErrReply(t, "Cannot create account with more access than yourself.")
694 err := cc.Server.NewUser(userLogin, string(getField(FieldUserName, &subFields).Data), string(getField(FieldUserPassword, &subFields).Data), newAccess)
696 return cc.NewErrReply(t, "Cannot create account because there is already an account with that login.")
701 return append(res, cc.NewReply(t))
704 // HandleNewUser creates a new user account
705 func HandleNewUser(cc *ClientConn, t *Transaction) (res []Transaction) {
706 if !cc.Authorize(accessCreateUser) {
707 return cc.NewErrReply(t, "You are not allowed to create new accounts.")
710 login := string(encodeString(t.GetField(FieldUserLogin).Data))
712 // If the account already dataFile, reply with an error
713 if _, ok := cc.Server.Accounts[login]; ok {
714 return cc.NewErrReply(t, "Cannot create account "+login+" because there is already an account with that login.")
717 newAccess := accessBitmap{}
718 copy(newAccess[:], t.GetField(FieldUserAccess).Data)
720 // Prevent account from creating new account with greater permission
721 for i := 0; i < 64; i++ {
722 if newAccess.IsSet(i) {
723 if !cc.Authorize(i) {
724 return cc.NewErrReply(t, "Cannot create account with more access than yourself.")
729 if err := cc.Server.NewUser(login, string(t.GetField(FieldUserName).Data), string(t.GetField(FieldUserPassword).Data), newAccess); err != nil {
730 return cc.NewErrReply(t, "Cannot create account because there is already an account with that login.")
733 return append(res, cc.NewReply(t))
736 func HandleDeleteUser(cc *ClientConn, t *Transaction) (res []Transaction) {
737 if !cc.Authorize(accessDeleteUser) {
738 return cc.NewErrReply(t, "You are not allowed to delete accounts.")
741 login := string(encodeString(t.GetField(FieldUserLogin).Data))
743 if err := cc.Server.DeleteUser(login); err != nil {
747 return append(res, cc.NewReply(t))
750 // HandleUserBroadcast sends an Administrator Message to all connected clients of the server
751 func HandleUserBroadcast(cc *ClientConn, t *Transaction) (res []Transaction) {
752 if !cc.Authorize(accessBroadcast) {
753 return cc.NewErrReply(t, "You are not allowed to send broadcast messages.")
758 NewField(FieldData, t.GetField(FieldData).Data),
759 NewField(FieldChatOptions, []byte{0}),
762 return append(res, cc.NewReply(t))
765 // HandleGetClientInfoText returns user information for the specific user.
767 // Fields used in the request:
770 // Fields used in the reply:
772 // 101 Data User info text string
773 func HandleGetClientInfoText(cc *ClientConn, t *Transaction) (res []Transaction) {
774 if !cc.Authorize(accessGetClientInfo) {
775 return cc.NewErrReply(t, "You are not allowed to get client info.")
778 clientID := t.GetField(FieldUserID).Data
780 clientConn := cc.Server.Clients[[2]byte(clientID)]
781 if clientConn == nil {
782 return cc.NewErrReply(t, "User not found.")
785 res = append(res, cc.NewReply(t,
786 NewField(FieldData, []byte(clientConn.String())),
787 NewField(FieldUserName, clientConn.UserName),
792 func HandleGetUserNameList(cc *ClientConn, t *Transaction) (res []Transaction) {
793 return []Transaction{cc.NewReply(t, cc.Server.connectedUsers()...)}
796 func HandleTranAgreed(cc *ClientConn, t *Transaction) (res []Transaction) {
797 if t.GetField(FieldUserName).Data != nil {
798 if cc.Authorize(accessAnyName) {
799 cc.UserName = t.GetField(FieldUserName).Data
801 cc.UserName = []byte(cc.Account.Name)
805 cc.Icon = t.GetField(FieldUserIconID).Data
807 cc.logger = cc.logger.With("Name", string(cc.UserName))
808 cc.logger.Info("Login successful", "clientVersion", fmt.Sprintf("%v", func() int { i, _ := byteToInt(cc.Version); return i }()))
810 options := t.GetField(FieldOptions).Data
811 optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options)))
813 // Check refuse private PM option
816 defer cc.flagsMU.Unlock()
817 cc.Flags.Set(UserFlagRefusePM, optBitmap.Bit(UserOptRefusePM))
819 // Check refuse private chat option
820 cc.Flags.Set(UserFlagRefusePChat, optBitmap.Bit(UserOptRefuseChat))
822 // Check auto response
823 if optBitmap.Bit(UserOptAutoResponse) == 1 {
824 cc.AutoReply = t.GetField(FieldAutomaticResponse).Data
827 trans := cc.notifyOthers(
829 TranNotifyChangeUser, [2]byte{0, 0},
830 NewField(FieldUserName, cc.UserName),
831 NewField(FieldUserID, cc.ID[:]),
832 NewField(FieldUserIconID, cc.Icon),
833 NewField(FieldUserFlags, cc.Flags[:]),
836 res = append(res, trans...)
838 if cc.Server.Config.BannerFile != "" {
839 res = append(res, NewTransaction(TranServerBanner, cc.ID, NewField(FieldBannerType, []byte("JPEG"))))
842 res = append(res, cc.NewReply(t))
847 // HandleTranOldPostNews updates the flat news
848 // Fields used in this request:
850 func HandleTranOldPostNews(cc *ClientConn, t *Transaction) (res []Transaction) {
851 if !cc.Authorize(accessNewsPostArt) {
852 return cc.NewErrReply(t, "You are not allowed to post news.")
855 cc.Server.flatNewsMux.Lock()
856 defer cc.Server.flatNewsMux.Unlock()
858 newsDateTemplate := defaultNewsDateFormat
859 if cc.Server.Config.NewsDateFormat != "" {
860 newsDateTemplate = cc.Server.Config.NewsDateFormat
863 newsTemplate := defaultNewsTemplate
864 if cc.Server.Config.NewsDelimiter != "" {
865 newsTemplate = cc.Server.Config.NewsDelimiter
868 newsPost := fmt.Sprintf(newsTemplate+"\r", cc.UserName, time.Now().Format(newsDateTemplate), t.GetField(FieldData).Data)
869 newsPost = strings.ReplaceAll(newsPost, "\n", "\r")
871 // update news in memory
872 cc.Server.FlatNews = append([]byte(newsPost), cc.Server.FlatNews...)
874 // update news on disk
875 if err := cc.Server.FS.WriteFile(filepath.Join(cc.Server.ConfigDir, "MessageBoard.txt"), cc.Server.FlatNews, 0644); err != nil {
879 // Notify all clients of updated news
882 NewField(FieldData, []byte(newsPost)),
885 res = append(res, cc.NewReply(t))
889 func HandleDisconnectUser(cc *ClientConn, t *Transaction) (res []Transaction) {
890 if !cc.Authorize(accessDisconUser) {
891 return cc.NewErrReply(t, "You are not allowed to disconnect users.")
894 clientConn := cc.Server.Clients[[2]byte(t.GetField(FieldUserID).Data)]
896 if clientConn.Authorize(accessCannotBeDiscon) {
897 return cc.NewErrReply(t, clientConn.Account.Login+" is not allowed to be disconnected.")
900 // If FieldOptions is set, then the client IP is banned in addition to disconnected.
901 // 00 01 = temporary ban
902 // 00 02 = permanent ban
903 if t.GetField(FieldOptions).Data != nil {
904 switch t.GetField(FieldOptions).Data[1] {
906 // send message: "You are temporarily banned on this server"
907 cc.logger.Info("Disconnect & temporarily ban " + string(clientConn.UserName))
909 res = append(res, NewTransaction(
912 NewField(FieldData, []byte("You are temporarily banned on this server")),
913 NewField(FieldChatOptions, []byte{0, 0}),
916 banUntil := time.Now().Add(tempBanDuration)
917 cc.Server.banList[strings.Split(clientConn.RemoteAddr, ":")[0]] = &banUntil
919 // send message: "You are permanently banned on this server"
920 cc.logger.Info("Disconnect & ban " + string(clientConn.UserName))
922 res = append(res, NewTransaction(
925 NewField(FieldData, []byte("You are permanently banned on this server")),
926 NewField(FieldChatOptions, []byte{0, 0}),
929 cc.Server.banList[strings.Split(clientConn.RemoteAddr, ":")[0]] = nil
932 err := cc.Server.writeBanList()
938 // TODO: remove this awful hack
940 time.Sleep(1 * time.Second)
941 clientConn.Disconnect()
944 return append(res, cc.NewReply(t))
947 // HandleGetNewsCatNameList returns a list of news categories for a path
948 // Fields used in the request:
949 // 325 News path (Optional)
950 func HandleGetNewsCatNameList(cc *ClientConn, t *Transaction) (res []Transaction) {
951 if !cc.Authorize(accessNewsReadArt) {
952 return cc.NewErrReply(t, "You are not allowed to read news.")
955 pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
956 cats := cc.Server.GetNewsCatByPath(pathStrs)
958 // To store the keys in slice in sorted order
959 keys := make([]string, len(cats))
961 for k := range cats {
967 var fieldData []Field
968 for _, k := range keys {
971 b, _ := io.ReadAll(&cat)
973 fieldData = append(fieldData, NewField(FieldNewsCatListData15, b))
976 res = append(res, cc.NewReply(t, fieldData...))
980 func HandleNewNewsCat(cc *ClientConn, t *Transaction) (res []Transaction) {
981 if !cc.Authorize(accessNewsCreateCat) {
982 return cc.NewErrReply(t, "You are not allowed to create news categories.")
985 name := string(t.GetField(FieldNewsCatName).Data)
986 pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
988 cats := cc.Server.GetNewsCatByPath(pathStrs)
989 cats[name] = NewsCategoryListData15{
992 Articles: map[uint32]*NewsArtData{},
993 SubCats: make(map[string]NewsCategoryListData15),
996 if err := cc.Server.writeThreadedNews(); err != nil {
999 res = append(res, cc.NewReply(t))
1003 // Fields used in the request:
1004 // 322 News category Name
1006 func HandleNewNewsFldr(cc *ClientConn, t *Transaction) (res []Transaction) {
1007 if !cc.Authorize(accessNewsCreateFldr) {
1008 return cc.NewErrReply(t, "You are not allowed to create news folders.")
1011 name := string(t.GetField(FieldFileName).Data)
1012 pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
1014 cats := cc.Server.GetNewsCatByPath(pathStrs)
1015 cats[name] = NewsCategoryListData15{
1017 Type: [2]byte{0, 2},
1018 Articles: map[uint32]*NewsArtData{},
1019 SubCats: make(map[string]NewsCategoryListData15),
1021 if err := cc.Server.writeThreadedNews(); err != nil {
1024 res = append(res, cc.NewReply(t))
1028 // HandleGetNewsArtData gets the list of article names at the specified news path.
1030 // Fields used in the request:
1031 // 325 News path Optional
1033 // Fields used in the reply:
1034 // 321 News article list data Optional
1035 func HandleGetNewsArtNameList(cc *ClientConn, t *Transaction) (res []Transaction) {
1036 if !cc.Authorize(accessNewsReadArt) {
1037 return cc.NewErrReply(t, "You are not allowed to read news.")
1040 pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
1042 var cat NewsCategoryListData15
1043 cats := cc.Server.ThreadedNews.Categories
1045 for _, fp := range pathStrs {
1047 cats = cats[fp].SubCats
1050 nald := cat.GetNewsArtListData()
1052 b, err := io.ReadAll(&nald)
1057 res = append(res, cc.NewReply(t, NewField(FieldNewsArtListData, b)))
1061 // HandleGetNewsArtData requests information about the specific news article.
1062 // Fields used in the request:
1066 // 326 News article ID
1067 // 327 News article data flavor
1069 // Fields used in the reply:
1070 // 328 News article title
1071 // 329 News article poster
1072 // 330 News article date
1073 // 331 Previous article ID
1074 // 332 Next article ID
1075 // 335 Parent article ID
1076 // 336 First child article ID
1077 // 327 News article data flavor "Should be “text/plain”
1078 // 333 News article data Optional (if data flavor is “text/plain”)
1079 func HandleGetNewsArtData(cc *ClientConn, t *Transaction) (res []Transaction) {
1080 if !cc.Authorize(accessNewsReadArt) {
1081 return cc.NewErrReply(t, "You are not allowed to read news.")
1084 var cat NewsCategoryListData15
1085 cats := cc.Server.ThreadedNews.Categories
1087 for _, fp := range ReadNewsPath(t.GetField(FieldNewsPath).Data) {
1089 cats = cats[fp].SubCats
1092 // The official Hotline clients will send the article ID as 2 bytes if possible, but
1093 // some third party clients such as Frogblast and Heildrun will always send 4 bytes
1094 convertedID, err := byteToInt(t.GetField(FieldNewsArtID).Data)
1099 art := cat.Articles[uint32(convertedID)]
1101 return append(res, cc.NewReply(t))
1104 res = append(res, cc.NewReply(t,
1105 NewField(FieldNewsArtTitle, []byte(art.Title)),
1106 NewField(FieldNewsArtPoster, []byte(art.Poster)),
1107 NewField(FieldNewsArtDate, art.Date[:]),
1108 NewField(FieldNewsArtPrevArt, art.PrevArt[:]),
1109 NewField(FieldNewsArtNextArt, art.NextArt[:]),
1110 NewField(FieldNewsArtParentArt, art.ParentArt[:]),
1111 NewField(FieldNewsArt1stChildArt, art.FirstChildArt[:]),
1112 NewField(FieldNewsArtDataFlav, []byte("text/plain")),
1113 NewField(FieldNewsArtData, []byte(art.Data)),
1118 // HandleDelNewsItem deletes an existing threaded news folder or category from the server.
1119 // Fields used in the request:
1121 // Fields used in the reply:
1123 func HandleDelNewsItem(cc *ClientConn, t *Transaction) (res []Transaction) {
1124 pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
1126 cats := cc.Server.ThreadedNews.Categories
1127 delName := pathStrs[len(pathStrs)-1]
1128 if len(pathStrs) > 1 {
1129 for _, fp := range pathStrs[0 : len(pathStrs)-1] {
1130 cats = cats[fp].SubCats
1134 if cats[delName].Type == [2]byte{0, 3} {
1135 if !cc.Authorize(accessNewsDeleteCat) {
1136 return cc.NewErrReply(t, "You are not allowed to delete news categories.")
1139 if !cc.Authorize(accessNewsDeleteFldr) {
1140 return cc.NewErrReply(t, "You are not allowed to delete news folders.")
1144 delete(cats, delName)
1146 if err := cc.Server.writeThreadedNews(); err != nil {
1150 return append(res, cc.NewReply(t))
1153 func HandleDelNewsArt(cc *ClientConn, t *Transaction) (res []Transaction) {
1154 if !cc.Authorize(accessNewsDeleteArt) {
1155 return cc.NewErrReply(t, "You are not allowed to delete news articles.")
1161 // 326 News article ID
1162 // 337 News article – recursive delete Delete child articles (1) or not (0)
1163 pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
1164 ID, err := byteToInt(t.GetField(FieldNewsArtID).Data)
1169 // TODO: Delete recursive
1170 cats := cc.Server.GetNewsCatByPath(pathStrs[:len(pathStrs)-1])
1172 catName := pathStrs[len(pathStrs)-1]
1173 cat := cats[catName]
1175 delete(cat.Articles, uint32(ID))
1178 if err := cc.Server.writeThreadedNews(); err != nil {
1182 res = append(res, cc.NewReply(t))
1188 // 326 News article ID ID of the parent article?
1189 // 328 News article title
1190 // 334 News article flags
1191 // 327 News article data flavor Currently “text/plain”
1192 // 333 News article data
1193 func HandlePostNewsArt(cc *ClientConn, t *Transaction) (res []Transaction) {
1194 if !cc.Authorize(accessNewsPostArt) {
1195 return cc.NewErrReply(t, "You are not allowed to post news articles.")
1198 pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
1199 cats := cc.Server.GetNewsCatByPath(pathStrs[:len(pathStrs)-1])
1201 catName := pathStrs[len(pathStrs)-1]
1202 cat := cats[catName]
1204 artID, err := byteToInt(t.GetField(FieldNewsArtID).Data)
1208 convertedArtID := uint32(artID)
1209 bs := make([]byte, 4)
1210 binary.BigEndian.PutUint32(bs, convertedArtID)
1212 cc.Server.mux.Lock()
1213 defer cc.Server.mux.Unlock()
1215 newArt := NewsArtData{
1216 Title: string(t.GetField(FieldNewsArtTitle).Data),
1217 Poster: string(cc.UserName),
1218 Date: toHotlineTime(time.Now()),
1219 ParentArt: [4]byte(bs),
1220 DataFlav: []byte("text/plain"),
1221 Data: string(t.GetField(FieldNewsArtData).Data),
1225 for k := range cat.Articles {
1226 keys = append(keys, int(k))
1232 prevID := uint32(keys[len(keys)-1])
1235 binary.BigEndian.PutUint32(newArt.PrevArt[:], prevID)
1237 // Set next article ID
1238 binary.BigEndian.PutUint32(cat.Articles[prevID].NextArt[:], nextID)
1241 // Update parent article with first child reply
1242 parentID := convertedArtID
1244 parentArt := cat.Articles[parentID]
1246 if parentArt.FirstChildArt == [4]byte{0, 0, 0, 0} {
1247 binary.BigEndian.PutUint32(parentArt.FirstChildArt[:], nextID)
1251 cat.Articles[nextID] = &newArt
1254 if err := cc.Server.writeThreadedNews(); err != nil {
1258 return append(res, cc.NewReply(t))
1261 // HandleGetMsgs returns the flat news data
1262 func HandleGetMsgs(cc *ClientConn, t *Transaction) (res []Transaction) {
1263 if !cc.Authorize(accessNewsReadArt) {
1264 return cc.NewErrReply(t, "You are not allowed to read news.")
1267 res = append(res, cc.NewReply(t, NewField(FieldData, cc.Server.FlatNews)))
1272 func HandleDownloadFile(cc *ClientConn, t *Transaction) (res []Transaction) {
1273 if !cc.Authorize(accessDownloadFile) {
1274 return cc.NewErrReply(t, "You are not allowed to download files.")
1277 fileName := t.GetField(FieldFileName).Data
1278 filePath := t.GetField(FieldFilePath).Data
1279 resumeData := t.GetField(FieldFileResumeData).Data
1281 var dataOffset int64
1282 var frd FileResumeData
1283 if resumeData != nil {
1284 if err := frd.UnmarshalBinary(t.GetField(FieldFileResumeData).Data); err != nil {
1287 // TODO: handle rsrc fork offset
1288 dataOffset = int64(binary.BigEndian.Uint32(frd.ForkInfoList[0].DataSize[:]))
1291 fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
1296 hlFile, err := newFileWrapper(cc.Server.FS, fullFilePath, dataOffset)
1301 xferSize := hlFile.ffo.TransferSize(0)
1303 ft := cc.newFileTransfer(FileDownload, fileName, filePath, xferSize)
1305 // TODO: refactor to remove this
1306 if resumeData != nil {
1307 var frd FileResumeData
1308 if err := frd.UnmarshalBinary(t.GetField(FieldFileResumeData).Data); err != nil {
1311 ft.fileResumeData = &frd
1314 // Optional field for when a HL v1.5+ client requests file preview
1315 // Used only for TEXT, JPEG, GIFF, BMP or PICT files
1316 // The value will always be 2
1317 if t.GetField(FieldFileTransferOptions).Data != nil {
1318 ft.options = t.GetField(FieldFileTransferOptions).Data
1319 xferSize = hlFile.ffo.FlatFileDataForkHeader.DataSize[:]
1322 res = append(res, cc.NewReply(t,
1323 NewField(FieldRefNum, ft.refNum[:]),
1324 NewField(FieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count
1325 NewField(FieldTransferSize, xferSize),
1326 NewField(FieldFileSize, hlFile.ffo.FlatFileDataForkHeader.DataSize[:]),
1332 // Download all files from the specified folder and sub-folders
1333 func HandleDownloadFolder(cc *ClientConn, t *Transaction) (res []Transaction) {
1334 if !cc.Authorize(accessDownloadFile) {
1335 return cc.NewErrReply(t, "You are not allowed to download folders.")
1338 fullFilePath, err := readPath(cc.Server.Config.FileRoot, t.GetField(FieldFilePath).Data, t.GetField(FieldFileName).Data)
1343 transferSize, err := CalcTotalSize(fullFilePath)
1347 itemCount, err := CalcItemCount(fullFilePath)
1351 spew.Dump(itemCount)
1353 fileTransfer := cc.newFileTransfer(FolderDownload, t.GetField(FieldFileName).Data, t.GetField(FieldFilePath).Data, transferSize)
1356 _, err = fp.Write(t.GetField(FieldFilePath).Data)
1361 res = append(res, cc.NewReply(t,
1362 NewField(FieldRefNum, fileTransfer.refNum[:]),
1363 NewField(FieldTransferSize, transferSize),
1364 NewField(FieldFolderItemCount, itemCount),
1365 NewField(FieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count
1370 // Upload all files from the local folder and its subfolders to the specified path on the server
1371 // Fields used in the request
1374 // 108 transfer size Total size of all items in the folder
1375 // 220 Folder item count
1376 // 204 File transfer options "Optional Currently set to 1" (TODO: ??)
1377 func HandleUploadFolder(cc *ClientConn, t *Transaction) (res []Transaction) {
1379 if t.GetField(FieldFilePath).Data != nil {
1380 if _, err := fp.Write(t.GetField(FieldFilePath).Data); err != nil {
1385 // Handle special cases for Upload and Drop Box folders
1386 if !cc.Authorize(accessUploadAnywhere) {
1387 if !fp.IsUploadDir() && !fp.IsDropbox() {
1388 return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload of the folder \"%v\" because you are only allowed to upload to the \"Uploads\" folder.", string(t.GetField(FieldFileName).Data)))
1392 fileTransfer := cc.newFileTransfer(FolderUpload,
1393 t.GetField(FieldFileName).Data,
1394 t.GetField(FieldFilePath).Data,
1395 t.GetField(FieldTransferSize).Data,
1398 fileTransfer.FolderItemCount = t.GetField(FieldFolderItemCount).Data
1400 return append(res, cc.NewReply(t, NewField(FieldRefNum, fileTransfer.refNum[:])))
1404 // Fields used in the request:
1407 // 204 File transfer options "Optional
1408 // Used only to resume download, currently has value 2"
1409 // 108 File transfer size "Optional used if download is not resumed"
1410 func HandleUploadFile(cc *ClientConn, t *Transaction) (res []Transaction) {
1411 if !cc.Authorize(accessUploadFile) {
1412 return cc.NewErrReply(t, "You are not allowed to upload files.")
1415 fileName := t.GetField(FieldFileName).Data
1416 filePath := t.GetField(FieldFilePath).Data
1417 transferOptions := t.GetField(FieldFileTransferOptions).Data
1418 transferSize := t.GetField(FieldTransferSize).Data // not sent for resume
1421 if filePath != nil {
1422 if _, err := fp.Write(filePath); err != nil {
1427 // Handle special cases for Upload and Drop Box folders
1428 if !cc.Authorize(accessUploadAnywhere) {
1429 if !fp.IsUploadDir() && !fp.IsDropbox() {
1430 return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload of the file \"%v\" because you are only allowed to upload to the \"Uploads\" folder.", string(fileName)))
1433 fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
1438 if _, err := cc.Server.FS.Stat(fullFilePath); err == nil {
1439 return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload because there is already a file named \"%v\". Try choosing a different Name.", string(fileName)))
1442 ft := cc.newFileTransfer(FileUpload, fileName, filePath, transferSize)
1444 replyT := cc.NewReply(t, NewField(FieldRefNum, ft.refNum[:]))
1446 // client has requested to resume a partially transferred file
1447 if transferOptions != nil {
1448 fileInfo, err := cc.Server.FS.Stat(fullFilePath + incompleteFileSuffix)
1453 offset := make([]byte, 4)
1454 binary.BigEndian.PutUint32(offset, uint32(fileInfo.Size()))
1456 fileResumeData := NewFileResumeData([]ForkInfoList{
1457 *NewForkInfoList(offset),
1460 b, _ := fileResumeData.BinaryMarshal()
1462 ft.TransferSize = offset
1464 replyT.Fields = append(replyT.Fields, NewField(FieldFileResumeData, b))
1467 res = append(res, replyT)
1471 func HandleSetClientUserInfo(cc *ClientConn, t *Transaction) (res []Transaction) {
1472 if len(t.GetField(FieldUserIconID).Data) == 4 {
1473 cc.Icon = t.GetField(FieldUserIconID).Data[2:]
1475 cc.Icon = t.GetField(FieldUserIconID).Data
1477 if cc.Authorize(accessAnyName) {
1478 cc.UserName = t.GetField(FieldUserName).Data
1482 defer cc.flagsMU.Unlock()
1484 // the options field is only passed by the client versions > 1.2.3.
1485 options := t.GetField(FieldOptions).Data
1487 optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options)))
1488 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(cc.Flags[:])))
1490 flagBitmap.SetBit(flagBitmap, UserFlagRefusePM, optBitmap.Bit(UserOptRefusePM))
1491 binary.BigEndian.PutUint16(cc.Flags[:], uint16(flagBitmap.Int64()))
1493 flagBitmap.SetBit(flagBitmap, UserFlagRefusePChat, optBitmap.Bit(UserOptRefuseChat))
1494 binary.BigEndian.PutUint16(cc.Flags[:], uint16(flagBitmap.Int64()))
1496 // Check auto response
1497 if optBitmap.Bit(UserOptAutoResponse) == 1 {
1498 cc.AutoReply = t.GetField(FieldAutomaticResponse).Data
1500 cc.AutoReply = []byte{}
1504 for _, c := range cc.Server.Clients {
1505 res = append(res, NewTransaction(
1506 TranNotifyChangeUser,
1508 NewField(FieldUserID, cc.ID[:]),
1509 NewField(FieldUserIconID, cc.Icon),
1510 NewField(FieldUserFlags, cc.Flags[:]),
1511 NewField(FieldUserName, cc.UserName),
1518 // HandleKeepAlive responds to keepalive transactions with an empty reply
1519 // * HL 1.9.2 Client sends keepalive msg every 3 minutes
1520 // * HL 1.2.3 Client doesn't send keepalives
1521 func HandleKeepAlive(cc *ClientConn, t *Transaction) (res []Transaction) {
1522 res = append(res, cc.NewReply(t))
1527 func HandleGetFileNameList(cc *ClientConn, t *Transaction) (res []Transaction) {
1528 fullPath, err := readPath(
1529 cc.Server.Config.FileRoot,
1530 t.GetField(FieldFilePath).Data,
1538 if t.GetField(FieldFilePath).Data != nil {
1539 if _, err = fp.Write(t.GetField(FieldFilePath).Data); err != nil {
1544 // Handle special case for drop box folders
1545 if fp.IsDropbox() && !cc.Authorize(accessViewDropBoxes) {
1546 return cc.NewErrReply(t, "You are not allowed to view drop boxes.")
1549 fileNames, err := getFileNameList(fullPath, cc.Server.Config.IgnoreFiles)
1554 res = append(res, cc.NewReply(t, fileNames...))
1559 // =================================
1560 // Hotline private chat flow
1561 // =================================
1562 // 1. ClientA sends TranInviteNewChat to server with user ID to invite
1563 // 2. Server creates new ChatID
1564 // 3. Server sends TranInviteToChat to invitee
1565 // 4. Server replies to ClientA with new Chat ID
1567 // A dialog box pops up in the invitee client with options to accept or decline the invitation.
1568 // If Accepted is clicked:
1569 // 1. ClientB sends TranJoinChat with FieldChatID
1571 // HandleInviteNewChat invites users to new private chat
1572 func HandleInviteNewChat(cc *ClientConn, t *Transaction) (res []Transaction) {
1573 if !cc.Authorize(accessOpenChat) {
1574 return cc.NewErrReply(t, "You are not allowed to request private chat.")
1578 targetID := t.GetField(FieldUserID).Data
1579 newChatID := cc.Server.NewPrivateChat(cc)
1581 // Check if target user has "Refuse private chat" flag
1582 targetClient := cc.Server.Clients[[2]byte(targetID)]
1583 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(targetClient.Flags[:])))
1584 if flagBitmap.Bit(UserFlagRefusePChat) == 1 {
1589 NewField(FieldData, []byte(string(targetClient.UserName)+" does not accept private chats.")),
1590 NewField(FieldUserName, targetClient.UserName),
1591 NewField(FieldUserID, targetClient.ID[:]),
1592 NewField(FieldOptions, []byte{0, 2}),
1600 NewField(FieldChatID, newChatID[:]),
1601 NewField(FieldUserName, cc.UserName),
1602 NewField(FieldUserID, cc.ID[:]),
1609 NewField(FieldChatID, newChatID[:]),
1610 NewField(FieldUserName, cc.UserName),
1611 NewField(FieldUserID, cc.ID[:]),
1612 NewField(FieldUserIconID, cc.Icon),
1613 NewField(FieldUserFlags, cc.Flags[:]),
1620 func HandleInviteToChat(cc *ClientConn, t *Transaction) (res []Transaction) {
1621 if !cc.Authorize(accessOpenChat) {
1622 return cc.NewErrReply(t, "You are not allowed to request private chat.")
1626 targetID := t.GetField(FieldUserID).Data
1627 chatID := t.GetField(FieldChatID).Data
1629 return []Transaction{
1633 NewField(FieldChatID, chatID),
1634 NewField(FieldUserName, cc.UserName),
1635 NewField(FieldUserID, cc.ID[:]),
1639 NewField(FieldChatID, chatID),
1640 NewField(FieldUserName, cc.UserName),
1641 NewField(FieldUserID, cc.ID[:]),
1642 NewField(FieldUserIconID, cc.Icon),
1643 NewField(FieldUserFlags, cc.Flags[:]),
1648 func HandleRejectChatInvite(cc *ClientConn, t *Transaction) (res []Transaction) {
1649 chatID := [4]byte(t.GetField(FieldChatID).Data)
1650 privChat := cc.Server.PrivateChats[chatID]
1652 for _, c := range privChat.ClientConn {
1657 NewField(FieldChatID, chatID[:]),
1658 NewField(FieldData, append(cc.UserName, []byte(" declined invitation to chat")...)),
1666 // HandleJoinChat is sent from a v1.8+ Hotline client when the joins a private chat
1667 // Fields used in the reply:
1668 // * 115 Chat subject
1669 // * 300 User Name with info (Optional)
1670 // * 300 (more user names with info)
1671 func HandleJoinChat(cc *ClientConn, t *Transaction) (res []Transaction) {
1672 chatID := t.GetField(FieldChatID).Data
1674 privChat := cc.Server.PrivateChats[[4]byte(chatID)]
1676 // Send TranNotifyChatChangeUser to current members of the chat to inform of new user
1677 for _, c := range privChat.ClientConn {
1680 TranNotifyChatChangeUser,
1682 NewField(FieldChatID, chatID),
1683 NewField(FieldUserName, cc.UserName),
1684 NewField(FieldUserID, cc.ID[:]),
1685 NewField(FieldUserIconID, cc.Icon),
1686 NewField(FieldUserFlags, cc.Flags[:]),
1691 privChat.ClientConn[cc.ID] = cc
1693 replyFields := []Field{NewField(FieldChatSubject, []byte(privChat.Subject))}
1694 for _, c := range privChat.ClientConn {
1695 b, err := io.ReadAll(&User{
1699 Name: string(c.UserName),
1704 replyFields = append(replyFields, NewField(FieldUsernameWithInfo, b))
1707 res = append(res, cc.NewReply(t, replyFields...))
1711 // HandleLeaveChat is sent from a v1.8+ Hotline client when the user exits a private chat
1712 // Fields used in the request:
1713 // - 114 FieldChatID
1715 // Reply is not expected.
1716 func HandleLeaveChat(cc *ClientConn, t *Transaction) (res []Transaction) {
1717 chatID := t.GetField(FieldChatID).Data
1719 privChat, ok := cc.Server.PrivateChats[[4]byte(chatID)]
1724 delete(privChat.ClientConn, cc.ID)
1726 // Notify members of the private chat that the user has left
1727 for _, c := range privChat.ClientConn {
1730 TranNotifyChatDeleteUser,
1732 NewField(FieldChatID, chatID),
1733 NewField(FieldUserID, cc.ID[:]),
1741 // HandleSetChatSubject is sent from a v1.8+ Hotline client when the user sets a private chat subject
1742 // Fields used in the request:
1744 // * 115 Chat subject
1745 // Reply is not expected.
1746 func HandleSetChatSubject(cc *ClientConn, t *Transaction) (res []Transaction) {
1747 chatID := t.GetField(FieldChatID).Data
1749 privChat := cc.Server.PrivateChats[[4]byte(chatID)]
1750 privChat.Subject = string(t.GetField(FieldChatSubject).Data)
1752 for _, c := range privChat.ClientConn {
1755 TranNotifyChatSubject,
1757 NewField(FieldChatID, chatID),
1758 NewField(FieldChatSubject, t.GetField(FieldChatSubject).Data),
1766 // HandleMakeAlias makes a file alias using the specified path.
1767 // Fields used in the request:
1770 // 212 File new path Destination path
1772 // Fields used in the reply:
1774 func HandleMakeAlias(cc *ClientConn, t *Transaction) (res []Transaction) {
1775 if !cc.Authorize(accessMakeAlias) {
1776 return cc.NewErrReply(t, "You are not allowed to make aliases.")
1778 fileName := t.GetField(FieldFileName).Data
1779 filePath := t.GetField(FieldFilePath).Data
1780 fileNewPath := t.GetField(FieldFileNewPath).Data
1782 fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
1787 fullNewFilePath, err := readPath(cc.Server.Config.FileRoot, fileNewPath, fileName)
1792 cc.logger.Debug("Make alias", "src", fullFilePath, "dst", fullNewFilePath)
1794 if err := cc.Server.FS.Symlink(fullFilePath, fullNewFilePath); err != nil {
1795 return cc.NewErrReply(t, "Error creating alias")
1798 res = append(res, cc.NewReply(t))
1802 // HandleDownloadBanner handles requests for a new banner from the server
1803 // Fields used in the request:
1805 // Fields used in the reply:
1806 // 107 FieldRefNum Used later for transfer
1807 // 108 FieldTransferSize Size of data to be downloaded
1808 func HandleDownloadBanner(cc *ClientConn, t *Transaction) (res []Transaction) {
1809 ft := cc.newFileTransfer(bannerDownload, []byte{}, []byte{}, make([]byte, 4))
1810 binary.BigEndian.PutUint32(ft.TransferSize, uint32(len(cc.Server.banner)))
1812 return append(res, cc.NewReply(t,
1813 NewField(FieldRefNum, ft.refNum[:]),
1814 NewField(FieldTransferSize, ft.TransferSize),