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 func HandleChatSend(cc *ClientConn, t *Transaction) (res []Transaction) {
71 if !cc.Authorize(accessSendChat) {
72 return cc.NewErrReply(t, "You are not allowed to participate in chat.")
75 // Truncate long usernames
76 trunc := fmt.Sprintf("%13s", cc.UserName)
77 formattedMsg := fmt.Sprintf("\r%.14s: %s", trunc, t.GetField(FieldData).Data)
79 // By holding the option key, Hotline chat allows users to send /me formatted messages like:
80 // *** Halcyon does stuff
81 // This is indicated by the presence of the optional field FieldChatOptions set to a value of 1.
82 // Most clients do not send this option for normal chat messages.
83 if t.GetField(FieldChatOptions).Data != nil && bytes.Equal(t.GetField(FieldChatOptions).Data, []byte{0, 1}) {
84 formattedMsg = fmt.Sprintf("\r*** %s %s", cc.UserName, t.GetField(FieldData).Data)
87 // The ChatID field is used to identify messages as belonging to a private chat.
88 // All clients *except* Frogblast omit this field for public chat, but Frogblast sends a value of 00 00 00 00.
89 chatID := t.GetField(FieldChatID).Data
90 if chatID != nil && !bytes.Equal([]byte{0, 0, 0, 0}, chatID) {
91 privChat := cc.Server.PrivateChats[[4]byte(chatID)]
93 // send the message to all connected clients of the private chat
94 for _, c := range privChat.ClientConn {
95 res = append(res, NewTransaction(
98 NewField(FieldChatID, chatID),
99 NewField(FieldData, []byte(formattedMsg)),
105 //cc.Server.mux.Lock()
106 for _, c := range cc.Server.Clients {
107 if c == nil || cc.Account == nil {
110 // Skip clients that do not have the read chat permission.
111 if c.Authorize(accessReadChat) {
112 res = append(res, NewTransaction(TranChatMsg, c.ID, NewField(FieldData, []byte(formattedMsg))))
115 //cc.Server.mux.Unlock()
120 // HandleSendInstantMsg sends instant message to the user on the current server.
121 // Fields used in the request:
125 // One of the following values:
126 // - User message (myOpt_UserMessage = 1)
127 // - Refuse message (myOpt_RefuseMessage = 2)
128 // - Refuse chat (myOpt_RefuseChat = 3)
129 // - Automatic response (myOpt_AutomaticResponse = 4)"
131 // 214 Quoting message Optional
133 // Fields used in the reply:
135 func HandleSendInstantMsg(cc *ClientConn, t *Transaction) (res []Transaction) {
136 if !cc.Authorize(accessSendPrivMsg) {
137 return cc.NewErrReply(t, "You are not allowed to send private messages.")
140 msg := t.GetField(FieldData)
141 userID := t.GetField(FieldUserID)
143 reply := NewTransaction(
145 [2]byte(userID.Data),
146 NewField(FieldData, msg.Data),
147 NewField(FieldUserName, cc.UserName),
148 NewField(FieldUserID, cc.ID[:]),
149 NewField(FieldOptions, []byte{0, 1}),
152 // Later versions of Hotline include the original message in the FieldQuotingMsg field so
153 // the receiving client can display both the received message and what it is in reply to
154 if t.GetField(FieldQuotingMsg).Data != nil {
155 reply.Fields = append(reply.Fields, NewField(FieldQuotingMsg, t.GetField(FieldQuotingMsg).Data))
158 otherClient, ok := cc.Server.Clients[[2]byte(userID.Data)]
163 // Check if target user has "Refuse private messages" flag
164 if otherClient.Flags.IsSet(UserFlagRefusePM) {
169 NewField(FieldData, []byte(string(otherClient.UserName)+" does not accept private messages.")),
170 NewField(FieldUserName, otherClient.UserName),
171 NewField(FieldUserID, otherClient.ID[:]),
172 NewField(FieldOptions, []byte{0, 2}),
176 res = append(res, reply)
179 // Respond with auto reply if other client has it enabled
180 if len(otherClient.AutoReply) > 0 {
185 NewField(FieldData, otherClient.AutoReply),
186 NewField(FieldUserName, otherClient.UserName),
187 NewField(FieldUserID, otherClient.ID[:]),
188 NewField(FieldOptions, []byte{0, 1}),
193 return append(res, cc.NewReply(t))
196 var fileTypeFLDR = [4]byte{0x66, 0x6c, 0x64, 0x72}
198 func HandleGetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction) {
199 fileName := t.GetField(FieldFileName).Data
200 filePath := t.GetField(FieldFilePath).Data
202 fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
207 fw, err := newFileWrapper(cc.Server.FS, fullFilePath, 0)
212 encodedName, err := txtEncoder.String(fw.name)
218 NewField(FieldFileName, []byte(encodedName)),
219 NewField(FieldFileTypeString, fw.ffo.FlatFileInformationFork.friendlyType()),
220 NewField(FieldFileCreatorString, fw.ffo.FlatFileInformationFork.friendlyCreator()),
221 NewField(FieldFileType, fw.ffo.FlatFileInformationFork.TypeSignature[:]),
222 NewField(FieldFileCreateDate, fw.ffo.FlatFileInformationFork.CreateDate[:]),
223 NewField(FieldFileModifyDate, fw.ffo.FlatFileInformationFork.ModifyDate[:]),
226 // Include the optional FileComment field if there is a comment.
227 if len(fw.ffo.FlatFileInformationFork.Comment) != 0 {
228 fields = append(fields, NewField(FieldFileComment, fw.ffo.FlatFileInformationFork.Comment))
231 // Include the FileSize field for files.
232 if fw.ffo.FlatFileInformationFork.TypeSignature != fileTypeFLDR {
233 fields = append(fields, NewField(FieldFileSize, fw.totalSize()))
236 res = append(res, cc.NewReply(t, fields...))
240 // HandleSetFileInfo updates a file or folder Name and/or comment from the Get Info window
241 // Fields used in the request:
243 // * 202 File path Optional
244 // * 211 File new Name Optional
245 // * 210 File comment Optional
246 // Fields used in the reply: None
247 func HandleSetFileInfo(cc *ClientConn, t *Transaction) (res []Transaction) {
248 fileName := t.GetField(FieldFileName).Data
249 filePath := t.GetField(FieldFilePath).Data
251 fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
256 fi, err := cc.Server.FS.Stat(fullFilePath)
261 hlFile, err := newFileWrapper(cc.Server.FS, fullFilePath, 0)
265 if t.GetField(FieldFileComment).Data != nil {
266 switch mode := fi.Mode(); {
268 if !cc.Authorize(accessSetFolderComment) {
269 return cc.NewErrReply(t, "You are not allowed to set comments for folders.")
271 case mode.IsRegular():
272 if !cc.Authorize(accessSetFileComment) {
273 return cc.NewErrReply(t, "You are not allowed to set comments for files.")
277 if err := hlFile.ffo.FlatFileInformationFork.setComment(t.GetField(FieldFileComment).Data); err != nil {
280 w, err := hlFile.infoForkWriter()
284 _, err = io.Copy(w, &hlFile.ffo.FlatFileInformationFork)
290 fullNewFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, t.GetField(FieldFileNewName).Data)
295 fileNewName := t.GetField(FieldFileNewName).Data
297 if fileNewName != nil {
298 switch mode := fi.Mode(); {
300 if !cc.Authorize(accessRenameFolder) {
301 return cc.NewErrReply(t, "You are not allowed to rename folders.")
303 err = os.Rename(fullFilePath, fullNewFilePath)
304 if os.IsNotExist(err) {
305 return cc.NewErrReply(t, "Cannot rename folder "+string(fileName)+" because it does not exist or cannot be found.")
308 case mode.IsRegular():
309 if !cc.Authorize(accessRenameFile) {
310 return cc.NewErrReply(t, "You are not allowed to rename files.")
312 fileDir, err := readPath(cc.Server.Config.FileRoot, filePath, []byte{})
316 hlFile.name, err = txtDecoder.String(string(fileNewName))
321 err = hlFile.move(fileDir)
322 if os.IsNotExist(err) {
323 return cc.NewErrReply(t, "Cannot rename file "+string(fileName)+" because it does not exist or cannot be found.")
331 res = append(res, cc.NewReply(t))
335 // HandleDeleteFile deletes a file or folder
336 // Fields used in the request:
339 // Fields used in the reply: none
340 func HandleDeleteFile(cc *ClientConn, t *Transaction) (res []Transaction) {
341 fileName := t.GetField(FieldFileName).Data
342 filePath := t.GetField(FieldFilePath).Data
344 fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
349 hlFile, err := newFileWrapper(cc.Server.FS, fullFilePath, 0)
354 fi, err := hlFile.dataFile()
356 return cc.NewErrReply(t, "Cannot delete file "+string(fileName)+" because it does not exist or cannot be found.")
359 switch mode := fi.Mode(); {
361 if !cc.Authorize(accessDeleteFolder) {
362 return cc.NewErrReply(t, "You are not allowed to delete folders.")
364 case mode.IsRegular():
365 if !cc.Authorize(accessDeleteFile) {
366 return cc.NewErrReply(t, "You are not allowed to delete files.")
370 if err := hlFile.delete(); err != nil {
374 res = append(res, cc.NewReply(t))
378 // HandleMoveFile moves files or folders. Note: seemingly not documented
379 func HandleMoveFile(cc *ClientConn, t *Transaction) (res []Transaction) {
380 fileName := string(t.GetField(FieldFileName).Data)
382 filePath, err := readPath(cc.Server.Config.FileRoot, t.GetField(FieldFilePath).Data, t.GetField(FieldFileName).Data)
387 fileNewPath, err := readPath(cc.Server.Config.FileRoot, t.GetField(FieldFileNewPath).Data, nil)
392 cc.logger.Info("Move file", "src", filePath+"/"+fileName, "dst", fileNewPath+"/"+fileName)
394 hlFile, err := newFileWrapper(cc.Server.FS, filePath, 0)
399 fi, err := hlFile.dataFile()
401 return cc.NewErrReply(t, "Cannot delete file "+fileName+" because it does not exist or cannot be found.")
403 switch mode := fi.Mode(); {
405 if !cc.Authorize(accessMoveFolder) {
406 return cc.NewErrReply(t, "You are not allowed to move folders.")
408 case mode.IsRegular():
409 if !cc.Authorize(accessMoveFile) {
410 return cc.NewErrReply(t, "You are not allowed to move files.")
413 if err := hlFile.move(fileNewPath); err != nil {
416 // TODO: handle other possible errors; e.g. fileWrapper delete fails due to fileWrapper permission issue
418 res = append(res, cc.NewReply(t))
422 func HandleNewFolder(cc *ClientConn, t *Transaction) (res []Transaction) {
423 if !cc.Authorize(accessCreateFolder) {
424 return cc.NewErrReply(t, "You are not allowed to create folders.")
426 folderName := string(t.GetField(FieldFileName).Data)
428 folderName = path.Join("/", folderName)
432 // FieldFilePath is only present for nested paths
433 if t.GetField(FieldFilePath).Data != nil {
435 _, err := newFp.Write(t.GetField(FieldFilePath).Data)
440 for _, pathItem := range newFp.Items {
441 subPath = filepath.Join("/", subPath, string(pathItem.Name))
444 newFolderPath := path.Join(cc.Server.Config.FileRoot, subPath, folderName)
445 newFolderPath, err := txtDecoder.String(newFolderPath)
450 // TODO: check path and folder Name lengths
452 if _, err := cc.Server.FS.Stat(newFolderPath); !os.IsNotExist(err) {
453 msg := fmt.Sprintf("Cannot create folder \"%s\" because there is already a file or folder with that Name.", folderName)
454 return cc.NewErrReply(t, msg)
457 if err := cc.Server.FS.Mkdir(newFolderPath, 0777); err != nil {
458 msg := fmt.Sprintf("Cannot create folder \"%s\" because an error occurred.", folderName)
459 return cc.NewErrReply(t, msg)
462 res = append(res, cc.NewReply(t))
466 func HandleSetUser(cc *ClientConn, t *Transaction) (res []Transaction) {
467 if !cc.Authorize(accessModifyUser) {
468 return cc.NewErrReply(t, "You are not allowed to modify accounts.")
471 login := string(encodeString(t.GetField(FieldUserLogin).Data))
472 userName := string(t.GetField(FieldUserName).Data)
474 newAccessLvl := t.GetField(FieldUserAccess).Data
476 account := cc.Server.Accounts[login]
478 return cc.NewErrReply(t, "Account not found.")
480 account.Name = userName
481 copy(account.Access[:], newAccessLvl)
483 // If the password field is cleared in the Hotline edit user UI, the SetUser transaction does
484 // not include FieldUserPassword
485 if t.GetField(FieldUserPassword).Data == nil {
486 account.Password = hashAndSalt([]byte(""))
489 if !bytes.Equal([]byte{0}, t.GetField(FieldUserPassword).Data) {
490 account.Password = hashAndSalt(t.GetField(FieldUserPassword).Data)
493 out, err := yaml.Marshal(&account)
497 if err := os.WriteFile(filepath.Join(cc.Server.ConfigDir, "Users", login+".yaml"), out, 0666); err != nil {
501 // Notify connected clients logged in as the user of the new access level
502 for _, c := range cc.Server.Clients {
503 if c.Account.Login == login {
504 newT := NewTransaction(TranUserAccess, c.ID, NewField(FieldUserAccess, newAccessLvl))
505 res = append(res, newT)
507 if c.Authorize(accessDisconUser) {
508 c.Flags.Set(UserFlagAdmin, 1)
510 c.Flags.Set(UserFlagAdmin, 0)
513 c.Account.Access = account.Access
516 TranNotifyChangeUser,
517 NewField(FieldUserID, c.ID[:]),
518 NewField(FieldUserFlags, c.Flags[:]),
519 NewField(FieldUserName, c.UserName),
520 NewField(FieldUserIconID, c.Icon),
525 res = append(res, cc.NewReply(t))
529 func HandleGetUser(cc *ClientConn, t *Transaction) (res []Transaction) {
530 if !cc.Authorize(accessOpenUser) {
531 return cc.NewErrReply(t, "You are not allowed to view accounts.")
534 account := cc.Server.Accounts[string(t.GetField(FieldUserLogin).Data)]
536 return cc.NewErrReply(t, "Account does not exist.")
539 res = append(res, cc.NewReply(t,
540 NewField(FieldUserName, []byte(account.Name)),
541 NewField(FieldUserLogin, encodeString(t.GetField(FieldUserLogin).Data)),
542 NewField(FieldUserPassword, []byte(account.Password)),
543 NewField(FieldUserAccess, account.Access[:]),
548 func HandleListUsers(cc *ClientConn, t *Transaction) (res []Transaction) {
549 if !cc.Authorize(accessOpenUser) {
550 return cc.NewErrReply(t, "You are not allowed to view accounts.")
553 var userFields []Field
554 for _, acc := range cc.Server.Accounts {
556 b, err := io.ReadAll(&accCopy)
561 userFields = append(userFields, NewField(FieldData, b))
564 res = append(res, cc.NewReply(t, userFields...))
568 // HandleUpdateUser is used by the v1.5+ multi-user editor to perform account editing for multiple users at a time.
569 // An update can be a mix of these actions:
572 // * Modify user (including renaming the account login)
574 // The Transaction sent by the client includes one data field per user that was modified. This data field in turn
575 // contains another data field encoded in its payload with a varying number of sub fields depending on which action is
576 // performed. This seems to be the only place in the Hotline protocol where a data field contains another data field.
577 func HandleUpdateUser(cc *ClientConn, t *Transaction) (res []Transaction) {
578 for _, field := range t.Fields {
579 var subFields []Field
581 // Create a new scanner for parsing incoming bytes into transaction tokens
582 scanner := bufio.NewScanner(bytes.NewReader(field.Data[2:]))
583 scanner.Split(fieldScanner)
585 for i := 0; i < int(binary.BigEndian.Uint16(field.Data[0:2])); i++ {
589 if _, err := field.Write(scanner.Bytes()); err != nil {
592 subFields = append(subFields, field)
595 // If there's only one subfield, that indicates this is a delete operation for the login in FieldData
596 if len(subFields) == 1 {
597 if !cc.Authorize(accessDeleteUser) {
598 return cc.NewErrReply(t, "You are not allowed to delete accounts.")
601 login := string(encodeString(getField(FieldData, &subFields).Data))
602 cc.logger.Info("DeleteUser", "login", login)
604 if err := cc.Server.DeleteUser(login); err != nil {
610 // login of the account to update
611 var accountToUpdate, loginToRename string
613 // If FieldData is included, this is a rename operation where FieldData contains the login of the existing
614 // account and FieldUserLogin contains the new login.
615 if getField(FieldData, &subFields) != nil {
616 loginToRename = string(encodeString(getField(FieldData, &subFields).Data))
618 userLogin := string(encodeString(getField(FieldUserLogin, &subFields).Data))
619 if loginToRename != "" {
620 accountToUpdate = loginToRename
622 accountToUpdate = userLogin
625 // Check if accountToUpdate has an existing account. If so, we know we are updating an existing user.
626 if acc, ok := cc.Server.Accounts[accountToUpdate]; ok {
627 if loginToRename != "" {
628 cc.logger.Info("RenameUser", "prevLogin", accountToUpdate, "newLogin", userLogin)
630 cc.logger.Info("UpdateUser", "login", accountToUpdate)
633 // account exists, so this is an update action
634 if !cc.Authorize(accessModifyUser) {
635 return cc.NewErrReply(t, "You are not allowed to modify accounts.")
638 // This part is a bit tricky. There are three possibilities:
639 // 1) The transaction is intended to update the password.
640 // In this case, FieldUserPassword is sent with the new password.
641 // 2) The transaction is intended to remove the password.
642 // In this case, FieldUserPassword is not sent.
643 // 3) The transaction updates the users access bits, but not the password.
644 // In this case, FieldUserPassword is sent with zero as the only byte.
645 if getField(FieldUserPassword, &subFields) != nil {
646 newPass := getField(FieldUserPassword, &subFields).Data
647 if !bytes.Equal([]byte{0}, newPass) {
648 acc.Password = hashAndSalt(newPass)
651 acc.Password = hashAndSalt([]byte(""))
654 if getField(FieldUserAccess, &subFields) != nil {
655 copy(acc.Access[:], getField(FieldUserAccess, &subFields).Data)
658 err := cc.Server.UpdateUser(
659 string(encodeString(getField(FieldData, &subFields).Data)),
660 string(encodeString(getField(FieldUserLogin, &subFields).Data)),
661 string(getField(FieldUserName, &subFields).Data),
669 if !cc.Authorize(accessCreateUser) {
670 return cc.NewErrReply(t, "You are not allowed to create new accounts.")
673 cc.logger.Info("CreateUser", "login", userLogin)
675 newAccess := accessBitmap{}
676 copy(newAccess[:], getField(FieldUserAccess, &subFields).Data)
678 // Prevent account from creating new account with greater permission
679 for i := 0; i < 64; i++ {
680 if newAccess.IsSet(i) {
681 if !cc.Authorize(i) {
682 return cc.NewErrReply(t, "Cannot create account with more access than yourself.")
687 err := cc.Server.NewUser(userLogin, string(getField(FieldUserName, &subFields).Data), string(getField(FieldUserPassword, &subFields).Data), newAccess)
689 return cc.NewErrReply(t, "Cannot create account because there is already an account with that login.")
694 return append(res, cc.NewReply(t))
697 // HandleNewUser creates a new user account
698 func HandleNewUser(cc *ClientConn, t *Transaction) (res []Transaction) {
699 if !cc.Authorize(accessCreateUser) {
700 return cc.NewErrReply(t, "You are not allowed to create new accounts.")
703 login := string(encodeString(t.GetField(FieldUserLogin).Data))
705 // If the account already dataFile, reply with an error
706 if _, ok := cc.Server.Accounts[login]; ok {
707 return cc.NewErrReply(t, "Cannot create account "+login+" because there is already an account with that login.")
710 newAccess := accessBitmap{}
711 copy(newAccess[:], t.GetField(FieldUserAccess).Data)
713 // Prevent account from creating new account with greater permission
714 for i := 0; i < 64; i++ {
715 if newAccess.IsSet(i) {
716 if !cc.Authorize(i) {
717 return cc.NewErrReply(t, "Cannot create account with more access than yourself.")
722 if err := cc.Server.NewUser(login, string(t.GetField(FieldUserName).Data), string(t.GetField(FieldUserPassword).Data), newAccess); err != nil {
723 return cc.NewErrReply(t, "Cannot create account because there is already an account with that login.")
726 return append(res, cc.NewReply(t))
729 func HandleDeleteUser(cc *ClientConn, t *Transaction) (res []Transaction) {
730 if !cc.Authorize(accessDeleteUser) {
731 return cc.NewErrReply(t, "You are not allowed to delete accounts.")
734 login := string(encodeString(t.GetField(FieldUserLogin).Data))
736 if err := cc.Server.DeleteUser(login); err != nil {
740 return append(res, cc.NewReply(t))
743 // HandleUserBroadcast sends an Administrator Message to all connected clients of the server
744 func HandleUserBroadcast(cc *ClientConn, t *Transaction) (res []Transaction) {
745 if !cc.Authorize(accessBroadcast) {
746 return cc.NewErrReply(t, "You are not allowed to send broadcast messages.")
751 NewField(FieldData, t.GetField(FieldData).Data),
752 NewField(FieldChatOptions, []byte{0}),
755 return append(res, cc.NewReply(t))
758 // HandleGetClientInfoText returns user information for the specific user.
760 // Fields used in the request:
763 // Fields used in the reply:
765 // 101 Data User info text string
766 func HandleGetClientInfoText(cc *ClientConn, t *Transaction) (res []Transaction) {
767 if !cc.Authorize(accessGetClientInfo) {
768 return cc.NewErrReply(t, "You are not allowed to get client info.")
771 clientID := t.GetField(FieldUserID).Data
773 clientConn := cc.Server.Clients[[2]byte(clientID)]
774 if clientConn == nil {
775 return cc.NewErrReply(t, "User not found.")
778 res = append(res, cc.NewReply(t,
779 NewField(FieldData, []byte(clientConn.String())),
780 NewField(FieldUserName, clientConn.UserName),
785 func HandleGetUserNameList(cc *ClientConn, t *Transaction) (res []Transaction) {
786 return []Transaction{cc.NewReply(t, cc.Server.connectedUsers()...)}
789 func HandleTranAgreed(cc *ClientConn, t *Transaction) (res []Transaction) {
790 if t.GetField(FieldUserName).Data != nil {
791 if cc.Authorize(accessAnyName) {
792 cc.UserName = t.GetField(FieldUserName).Data
794 cc.UserName = []byte(cc.Account.Name)
798 cc.Icon = t.GetField(FieldUserIconID).Data
800 cc.logger = cc.logger.With("Name", string(cc.UserName))
801 cc.logger.Info("Login successful", "clientVersion", fmt.Sprintf("%v", func() int { i, _ := byteToInt(cc.Version); return i }()))
803 options := t.GetField(FieldOptions).Data
804 optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options)))
806 // Check refuse private PM option
809 defer cc.flagsMU.Unlock()
810 cc.Flags.Set(UserFlagRefusePM, optBitmap.Bit(UserOptRefusePM))
812 // Check refuse private chat option
813 cc.Flags.Set(UserFlagRefusePChat, optBitmap.Bit(UserOptRefuseChat))
815 // Check auto response
816 if optBitmap.Bit(UserOptAutoResponse) == 1 {
817 cc.AutoReply = t.GetField(FieldAutomaticResponse).Data
820 trans := cc.notifyOthers(
822 TranNotifyChangeUser, [2]byte{0, 0},
823 NewField(FieldUserName, cc.UserName),
824 NewField(FieldUserID, cc.ID[:]),
825 NewField(FieldUserIconID, cc.Icon),
826 NewField(FieldUserFlags, cc.Flags[:]),
829 res = append(res, trans...)
831 if cc.Server.Config.BannerFile != "" {
832 res = append(res, NewTransaction(TranServerBanner, cc.ID, NewField(FieldBannerType, []byte("JPEG"))))
835 res = append(res, cc.NewReply(t))
840 // HandleTranOldPostNews updates the flat news
841 // Fields used in this request:
843 func HandleTranOldPostNews(cc *ClientConn, t *Transaction) (res []Transaction) {
844 if !cc.Authorize(accessNewsPostArt) {
845 return cc.NewErrReply(t, "You are not allowed to post news.")
848 cc.Server.flatNewsMux.Lock()
849 defer cc.Server.flatNewsMux.Unlock()
851 newsDateTemplate := defaultNewsDateFormat
852 if cc.Server.Config.NewsDateFormat != "" {
853 newsDateTemplate = cc.Server.Config.NewsDateFormat
856 newsTemplate := defaultNewsTemplate
857 if cc.Server.Config.NewsDelimiter != "" {
858 newsTemplate = cc.Server.Config.NewsDelimiter
861 newsPost := fmt.Sprintf(newsTemplate+"\r", cc.UserName, time.Now().Format(newsDateTemplate), t.GetField(FieldData).Data)
862 newsPost = strings.ReplaceAll(newsPost, "\n", "\r")
864 // update news in memory
865 cc.Server.FlatNews = append([]byte(newsPost), cc.Server.FlatNews...)
867 // update news on disk
868 if err := cc.Server.FS.WriteFile(filepath.Join(cc.Server.ConfigDir, "MessageBoard.txt"), cc.Server.FlatNews, 0644); err != nil {
872 // Notify all clients of updated news
875 NewField(FieldData, []byte(newsPost)),
878 res = append(res, cc.NewReply(t))
882 func HandleDisconnectUser(cc *ClientConn, t *Transaction) (res []Transaction) {
883 if !cc.Authorize(accessDisconUser) {
884 return cc.NewErrReply(t, "You are not allowed to disconnect users.")
887 clientConn := cc.Server.Clients[[2]byte(t.GetField(FieldUserID).Data)]
889 if clientConn.Authorize(accessCannotBeDiscon) {
890 return cc.NewErrReply(t, clientConn.Account.Login+" is not allowed to be disconnected.")
893 // If FieldOptions is set, then the client IP is banned in addition to disconnected.
894 // 00 01 = temporary ban
895 // 00 02 = permanent ban
896 if t.GetField(FieldOptions).Data != nil {
897 switch t.GetField(FieldOptions).Data[1] {
899 // send message: "You are temporarily banned on this server"
900 cc.logger.Info("Disconnect & temporarily ban " + string(clientConn.UserName))
902 res = append(res, NewTransaction(
905 NewField(FieldData, []byte("You are temporarily banned on this server")),
906 NewField(FieldChatOptions, []byte{0, 0}),
909 banUntil := time.Now().Add(tempBanDuration)
910 cc.Server.banList[strings.Split(clientConn.RemoteAddr, ":")[0]] = &banUntil
912 // send message: "You are permanently banned on this server"
913 cc.logger.Info("Disconnect & ban " + string(clientConn.UserName))
915 res = append(res, NewTransaction(
918 NewField(FieldData, []byte("You are permanently banned on this server")),
919 NewField(FieldChatOptions, []byte{0, 0}),
922 cc.Server.banList[strings.Split(clientConn.RemoteAddr, ":")[0]] = nil
925 err := cc.Server.writeBanList()
931 // TODO: remove this awful hack
933 time.Sleep(1 * time.Second)
934 clientConn.Disconnect()
937 return append(res, cc.NewReply(t))
940 // HandleGetNewsCatNameList returns a list of news categories for a path
941 // Fields used in the request:
942 // 325 News path (Optional)
943 func HandleGetNewsCatNameList(cc *ClientConn, t *Transaction) (res []Transaction) {
944 if !cc.Authorize(accessNewsReadArt) {
945 return cc.NewErrReply(t, "You are not allowed to read news.")
948 pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
949 cats := cc.Server.GetNewsCatByPath(pathStrs)
951 // To store the keys in slice in sorted order
952 keys := make([]string, len(cats))
954 for k := range cats {
960 var fieldData []Field
961 for _, k := range keys {
964 b, _ := io.ReadAll(&cat)
966 fieldData = append(fieldData, NewField(FieldNewsCatListData15, b))
969 res = append(res, cc.NewReply(t, fieldData...))
973 func HandleNewNewsCat(cc *ClientConn, t *Transaction) (res []Transaction) {
974 if !cc.Authorize(accessNewsCreateCat) {
975 return cc.NewErrReply(t, "You are not allowed to create news categories.")
978 name := string(t.GetField(FieldNewsCatName).Data)
979 pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
981 cats := cc.Server.GetNewsCatByPath(pathStrs)
982 cats[name] = NewsCategoryListData15{
985 Articles: map[uint32]*NewsArtData{},
986 SubCats: make(map[string]NewsCategoryListData15),
989 if err := cc.Server.writeThreadedNews(); err != nil {
992 res = append(res, cc.NewReply(t))
996 // Fields used in the request:
997 // 322 News category Name
999 func HandleNewNewsFldr(cc *ClientConn, t *Transaction) (res []Transaction) {
1000 if !cc.Authorize(accessNewsCreateFldr) {
1001 return cc.NewErrReply(t, "You are not allowed to create news folders.")
1004 name := string(t.GetField(FieldFileName).Data)
1005 pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
1007 cats := cc.Server.GetNewsCatByPath(pathStrs)
1008 cats[name] = NewsCategoryListData15{
1010 Type: [2]byte{0, 2},
1011 Articles: map[uint32]*NewsArtData{},
1012 SubCats: make(map[string]NewsCategoryListData15),
1014 if err := cc.Server.writeThreadedNews(); err != nil {
1017 res = append(res, cc.NewReply(t))
1021 // HandleGetNewsArtData gets the list of article names at the specified news path.
1023 // Fields used in the request:
1024 // 325 News path Optional
1026 // Fields used in the reply:
1027 // 321 News article list data Optional
1028 func HandleGetNewsArtNameList(cc *ClientConn, t *Transaction) (res []Transaction) {
1029 if !cc.Authorize(accessNewsReadArt) {
1030 return cc.NewErrReply(t, "You are not allowed to read news.")
1033 pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
1035 var cat NewsCategoryListData15
1036 cats := cc.Server.ThreadedNews.Categories
1038 for _, fp := range pathStrs {
1040 cats = cats[fp].SubCats
1043 nald := cat.GetNewsArtListData()
1045 b, err := io.ReadAll(&nald)
1050 res = append(res, cc.NewReply(t, NewField(FieldNewsArtListData, b)))
1054 // HandleGetNewsArtData requests information about the specific news article.
1055 // Fields used in the request:
1059 // 326 News article ID
1060 // 327 News article data flavor
1062 // Fields used in the reply:
1063 // 328 News article title
1064 // 329 News article poster
1065 // 330 News article date
1066 // 331 Previous article ID
1067 // 332 Next article ID
1068 // 335 Parent article ID
1069 // 336 First child article ID
1070 // 327 News article data flavor "Should be “text/plain”
1071 // 333 News article data Optional (if data flavor is “text/plain”)
1072 func HandleGetNewsArtData(cc *ClientConn, t *Transaction) (res []Transaction) {
1073 if !cc.Authorize(accessNewsReadArt) {
1074 return cc.NewErrReply(t, "You are not allowed to read news.")
1077 var cat NewsCategoryListData15
1078 cats := cc.Server.ThreadedNews.Categories
1080 for _, fp := range ReadNewsPath(t.GetField(FieldNewsPath).Data) {
1082 cats = cats[fp].SubCats
1085 // The official Hotline clients will send the article ID as 2 bytes if possible, but
1086 // some third party clients such as Frogblast and Heildrun will always send 4 bytes
1087 convertedID, err := byteToInt(t.GetField(FieldNewsArtID).Data)
1092 art := cat.Articles[uint32(convertedID)]
1094 return append(res, cc.NewReply(t))
1097 res = append(res, cc.NewReply(t,
1098 NewField(FieldNewsArtTitle, []byte(art.Title)),
1099 NewField(FieldNewsArtPoster, []byte(art.Poster)),
1100 NewField(FieldNewsArtDate, art.Date[:]),
1101 NewField(FieldNewsArtPrevArt, art.PrevArt[:]),
1102 NewField(FieldNewsArtNextArt, art.NextArt[:]),
1103 NewField(FieldNewsArtParentArt, art.ParentArt[:]),
1104 NewField(FieldNewsArt1stChildArt, art.FirstChildArt[:]),
1105 NewField(FieldNewsArtDataFlav, []byte("text/plain")),
1106 NewField(FieldNewsArtData, []byte(art.Data)),
1111 // HandleDelNewsItem deletes an existing threaded news folder or category from the server.
1112 // Fields used in the request:
1114 // Fields used in the reply:
1116 func HandleDelNewsItem(cc *ClientConn, t *Transaction) (res []Transaction) {
1117 pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
1119 cats := cc.Server.ThreadedNews.Categories
1120 delName := pathStrs[len(pathStrs)-1]
1121 if len(pathStrs) > 1 {
1122 for _, fp := range pathStrs[0 : len(pathStrs)-1] {
1123 cats = cats[fp].SubCats
1127 if cats[delName].Type == [2]byte{0, 3} {
1128 if !cc.Authorize(accessNewsDeleteCat) {
1129 return cc.NewErrReply(t, "You are not allowed to delete news categories.")
1132 if !cc.Authorize(accessNewsDeleteFldr) {
1133 return cc.NewErrReply(t, "You are not allowed to delete news folders.")
1137 delete(cats, delName)
1139 if err := cc.Server.writeThreadedNews(); err != nil {
1143 return append(res, cc.NewReply(t))
1146 func HandleDelNewsArt(cc *ClientConn, t *Transaction) (res []Transaction) {
1147 if !cc.Authorize(accessNewsDeleteArt) {
1148 return cc.NewErrReply(t, "You are not allowed to delete news articles.")
1154 // 326 News article ID
1155 // 337 News article – recursive delete Delete child articles (1) or not (0)
1156 pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
1157 ID, err := byteToInt(t.GetField(FieldNewsArtID).Data)
1162 // TODO: Delete recursive
1163 cats := cc.Server.GetNewsCatByPath(pathStrs[:len(pathStrs)-1])
1165 catName := pathStrs[len(pathStrs)-1]
1166 cat := cats[catName]
1168 delete(cat.Articles, uint32(ID))
1171 if err := cc.Server.writeThreadedNews(); err != nil {
1175 res = append(res, cc.NewReply(t))
1181 // 326 News article ID ID of the parent article?
1182 // 328 News article title
1183 // 334 News article flags
1184 // 327 News article data flavor Currently “text/plain”
1185 // 333 News article data
1186 func HandlePostNewsArt(cc *ClientConn, t *Transaction) (res []Transaction) {
1187 if !cc.Authorize(accessNewsPostArt) {
1188 return cc.NewErrReply(t, "You are not allowed to post news articles.")
1191 pathStrs := ReadNewsPath(t.GetField(FieldNewsPath).Data)
1192 cats := cc.Server.GetNewsCatByPath(pathStrs[:len(pathStrs)-1])
1194 catName := pathStrs[len(pathStrs)-1]
1195 cat := cats[catName]
1197 artID, err := byteToInt(t.GetField(FieldNewsArtID).Data)
1201 convertedArtID := uint32(artID)
1202 bs := make([]byte, 4)
1203 binary.BigEndian.PutUint32(bs, convertedArtID)
1205 cc.Server.mux.Lock()
1206 defer cc.Server.mux.Unlock()
1208 newArt := NewsArtData{
1209 Title: string(t.GetField(FieldNewsArtTitle).Data),
1210 Poster: string(cc.UserName),
1211 Date: toHotlineTime(time.Now()),
1212 ParentArt: [4]byte(bs),
1213 DataFlav: []byte("text/plain"),
1214 Data: string(t.GetField(FieldNewsArtData).Data),
1218 for k := range cat.Articles {
1219 keys = append(keys, int(k))
1225 prevID := uint32(keys[len(keys)-1])
1228 binary.BigEndian.PutUint32(newArt.PrevArt[:], prevID)
1230 // Set next article ID
1231 binary.BigEndian.PutUint32(cat.Articles[prevID].NextArt[:], nextID)
1234 // Update parent article with first child reply
1235 parentID := convertedArtID
1237 parentArt := cat.Articles[parentID]
1239 if parentArt.FirstChildArt == [4]byte{0, 0, 0, 0} {
1240 binary.BigEndian.PutUint32(parentArt.FirstChildArt[:], nextID)
1244 cat.Articles[nextID] = &newArt
1247 if err := cc.Server.writeThreadedNews(); err != nil {
1251 return append(res, cc.NewReply(t))
1254 // HandleGetMsgs returns the flat news data
1255 func HandleGetMsgs(cc *ClientConn, t *Transaction) (res []Transaction) {
1256 if !cc.Authorize(accessNewsReadArt) {
1257 return cc.NewErrReply(t, "You are not allowed to read news.")
1260 res = append(res, cc.NewReply(t, NewField(FieldData, cc.Server.FlatNews)))
1265 func HandleDownloadFile(cc *ClientConn, t *Transaction) (res []Transaction) {
1266 if !cc.Authorize(accessDownloadFile) {
1267 return cc.NewErrReply(t, "You are not allowed to download files.")
1270 fileName := t.GetField(FieldFileName).Data
1271 filePath := t.GetField(FieldFilePath).Data
1272 resumeData := t.GetField(FieldFileResumeData).Data
1274 var dataOffset int64
1275 var frd FileResumeData
1276 if resumeData != nil {
1277 if err := frd.UnmarshalBinary(t.GetField(FieldFileResumeData).Data); err != nil {
1280 // TODO: handle rsrc fork offset
1281 dataOffset = int64(binary.BigEndian.Uint32(frd.ForkInfoList[0].DataSize[:]))
1284 fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
1289 hlFile, err := newFileWrapper(cc.Server.FS, fullFilePath, dataOffset)
1294 xferSize := hlFile.ffo.TransferSize(0)
1296 ft := cc.newFileTransfer(FileDownload, fileName, filePath, xferSize)
1298 // TODO: refactor to remove this
1299 if resumeData != nil {
1300 var frd FileResumeData
1301 if err := frd.UnmarshalBinary(t.GetField(FieldFileResumeData).Data); err != nil {
1304 ft.fileResumeData = &frd
1307 // Optional field for when a HL v1.5+ client requests file preview
1308 // Used only for TEXT, JPEG, GIFF, BMP or PICT files
1309 // The value will always be 2
1310 if t.GetField(FieldFileTransferOptions).Data != nil {
1311 ft.options = t.GetField(FieldFileTransferOptions).Data
1312 xferSize = hlFile.ffo.FlatFileDataForkHeader.DataSize[:]
1315 res = append(res, cc.NewReply(t,
1316 NewField(FieldRefNum, ft.refNum[:]),
1317 NewField(FieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count
1318 NewField(FieldTransferSize, xferSize),
1319 NewField(FieldFileSize, hlFile.ffo.FlatFileDataForkHeader.DataSize[:]),
1325 // Download all files from the specified folder and sub-folders
1326 func HandleDownloadFolder(cc *ClientConn, t *Transaction) (res []Transaction) {
1327 if !cc.Authorize(accessDownloadFile) {
1328 return cc.NewErrReply(t, "You are not allowed to download folders.")
1331 fullFilePath, err := readPath(cc.Server.Config.FileRoot, t.GetField(FieldFilePath).Data, t.GetField(FieldFileName).Data)
1336 transferSize, err := CalcTotalSize(fullFilePath)
1340 itemCount, err := CalcItemCount(fullFilePath)
1344 spew.Dump(itemCount)
1346 fileTransfer := cc.newFileTransfer(FolderDownload, t.GetField(FieldFileName).Data, t.GetField(FieldFilePath).Data, transferSize)
1349 _, err = fp.Write(t.GetField(FieldFilePath).Data)
1354 res = append(res, cc.NewReply(t,
1355 NewField(FieldRefNum, fileTransfer.refNum[:]),
1356 NewField(FieldTransferSize, transferSize),
1357 NewField(FieldFolderItemCount, itemCount),
1358 NewField(FieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count
1363 // Upload all files from the local folder and its subfolders to the specified path on the server
1364 // Fields used in the request
1367 // 108 transfer size Total size of all items in the folder
1368 // 220 Folder item count
1369 // 204 File transfer options "Optional Currently set to 1" (TODO: ??)
1370 func HandleUploadFolder(cc *ClientConn, t *Transaction) (res []Transaction) {
1372 if t.GetField(FieldFilePath).Data != nil {
1373 if _, err := fp.Write(t.GetField(FieldFilePath).Data); err != nil {
1378 // Handle special cases for Upload and Drop Box folders
1379 if !cc.Authorize(accessUploadAnywhere) {
1380 if !fp.IsUploadDir() && !fp.IsDropbox() {
1381 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)))
1385 fileTransfer := cc.newFileTransfer(FolderUpload,
1386 t.GetField(FieldFileName).Data,
1387 t.GetField(FieldFilePath).Data,
1388 t.GetField(FieldTransferSize).Data,
1391 fileTransfer.FolderItemCount = t.GetField(FieldFolderItemCount).Data
1393 return append(res, cc.NewReply(t, NewField(FieldRefNum, fileTransfer.refNum[:])))
1397 // Fields used in the request:
1400 // 204 File transfer options "Optional
1401 // Used only to resume download, currently has value 2"
1402 // 108 File transfer size "Optional used if download is not resumed"
1403 func HandleUploadFile(cc *ClientConn, t *Transaction) (res []Transaction) {
1404 if !cc.Authorize(accessUploadFile) {
1405 return cc.NewErrReply(t, "You are not allowed to upload files.")
1408 fileName := t.GetField(FieldFileName).Data
1409 filePath := t.GetField(FieldFilePath).Data
1410 transferOptions := t.GetField(FieldFileTransferOptions).Data
1411 transferSize := t.GetField(FieldTransferSize).Data // not sent for resume
1414 if filePath != nil {
1415 if _, err := fp.Write(filePath); err != nil {
1420 // Handle special cases for Upload and Drop Box folders
1421 if !cc.Authorize(accessUploadAnywhere) {
1422 if !fp.IsUploadDir() && !fp.IsDropbox() {
1423 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)))
1426 fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
1431 if _, err := cc.Server.FS.Stat(fullFilePath); err == nil {
1432 return cc.NewErrReply(t, fmt.Sprintf("Cannot accept upload because there is already a file named \"%v\". Try choosing a different Name.", string(fileName)))
1435 ft := cc.newFileTransfer(FileUpload, fileName, filePath, transferSize)
1437 replyT := cc.NewReply(t, NewField(FieldRefNum, ft.refNum[:]))
1439 // client has requested to resume a partially transferred file
1440 if transferOptions != nil {
1441 fileInfo, err := cc.Server.FS.Stat(fullFilePath + incompleteFileSuffix)
1446 offset := make([]byte, 4)
1447 binary.BigEndian.PutUint32(offset, uint32(fileInfo.Size()))
1449 fileResumeData := NewFileResumeData([]ForkInfoList{
1450 *NewForkInfoList(offset),
1453 b, _ := fileResumeData.BinaryMarshal()
1455 ft.TransferSize = offset
1457 replyT.Fields = append(replyT.Fields, NewField(FieldFileResumeData, b))
1460 res = append(res, replyT)
1464 func HandleSetClientUserInfo(cc *ClientConn, t *Transaction) (res []Transaction) {
1465 if len(t.GetField(FieldUserIconID).Data) == 4 {
1466 cc.Icon = t.GetField(FieldUserIconID).Data[2:]
1468 cc.Icon = t.GetField(FieldUserIconID).Data
1470 if cc.Authorize(accessAnyName) {
1471 cc.UserName = t.GetField(FieldUserName).Data
1475 defer cc.flagsMU.Unlock()
1477 // the options field is only passed by the client versions > 1.2.3.
1478 options := t.GetField(FieldOptions).Data
1480 optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options)))
1481 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(cc.Flags[:])))
1483 flagBitmap.SetBit(flagBitmap, UserFlagRefusePM, optBitmap.Bit(UserOptRefusePM))
1484 binary.BigEndian.PutUint16(cc.Flags[:], uint16(flagBitmap.Int64()))
1486 flagBitmap.SetBit(flagBitmap, UserFlagRefusePChat, optBitmap.Bit(UserOptRefuseChat))
1487 binary.BigEndian.PutUint16(cc.Flags[:], uint16(flagBitmap.Int64()))
1489 // Check auto response
1490 if optBitmap.Bit(UserOptAutoResponse) == 1 {
1491 cc.AutoReply = t.GetField(FieldAutomaticResponse).Data
1493 cc.AutoReply = []byte{}
1497 for _, c := range cc.Server.Clients {
1498 res = append(res, NewTransaction(
1499 TranNotifyChangeUser,
1501 NewField(FieldUserID, cc.ID[:]),
1502 NewField(FieldUserIconID, cc.Icon),
1503 NewField(FieldUserFlags, cc.Flags[:]),
1504 NewField(FieldUserName, cc.UserName),
1511 // HandleKeepAlive responds to keepalive transactions with an empty reply
1512 // * HL 1.9.2 Client sends keepalive msg every 3 minutes
1513 // * HL 1.2.3 Client doesn't send keepalives
1514 func HandleKeepAlive(cc *ClientConn, t *Transaction) (res []Transaction) {
1515 res = append(res, cc.NewReply(t))
1520 func HandleGetFileNameList(cc *ClientConn, t *Transaction) (res []Transaction) {
1521 fullPath, err := readPath(
1522 cc.Server.Config.FileRoot,
1523 t.GetField(FieldFilePath).Data,
1531 if t.GetField(FieldFilePath).Data != nil {
1532 if _, err = fp.Write(t.GetField(FieldFilePath).Data); err != nil {
1537 // Handle special case for drop box folders
1538 if fp.IsDropbox() && !cc.Authorize(accessViewDropBoxes) {
1539 return cc.NewErrReply(t, "You are not allowed to view drop boxes.")
1542 fileNames, err := getFileNameList(fullPath, cc.Server.Config.IgnoreFiles)
1547 res = append(res, cc.NewReply(t, fileNames...))
1552 // =================================
1553 // Hotline private chat flow
1554 // =================================
1555 // 1. ClientA sends TranInviteNewChat to server with user ID to invite
1556 // 2. Server creates new ChatID
1557 // 3. Server sends TranInviteToChat to invitee
1558 // 4. Server replies to ClientA with new Chat ID
1560 // A dialog box pops up in the invitee client with options to accept or decline the invitation.
1561 // If Accepted is clicked:
1562 // 1. ClientB sends TranJoinChat with FieldChatID
1564 // HandleInviteNewChat invites users to new private chat
1565 func HandleInviteNewChat(cc *ClientConn, t *Transaction) (res []Transaction) {
1566 if !cc.Authorize(accessOpenChat) {
1567 return cc.NewErrReply(t, "You are not allowed to request private chat.")
1571 targetID := t.GetField(FieldUserID).Data
1572 newChatID := cc.Server.NewPrivateChat(cc)
1574 // Check if target user has "Refuse private chat" flag
1575 targetClient := cc.Server.Clients[[2]byte(targetID)]
1576 flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(targetClient.Flags[:])))
1577 if flagBitmap.Bit(UserFlagRefusePChat) == 1 {
1582 NewField(FieldData, []byte(string(targetClient.UserName)+" does not accept private chats.")),
1583 NewField(FieldUserName, targetClient.UserName),
1584 NewField(FieldUserID, targetClient.ID[:]),
1585 NewField(FieldOptions, []byte{0, 2}),
1593 NewField(FieldChatID, newChatID[:]),
1594 NewField(FieldUserName, cc.UserName),
1595 NewField(FieldUserID, cc.ID[:]),
1602 NewField(FieldChatID, newChatID[:]),
1603 NewField(FieldUserName, cc.UserName),
1604 NewField(FieldUserID, cc.ID[:]),
1605 NewField(FieldUserIconID, cc.Icon),
1606 NewField(FieldUserFlags, cc.Flags[:]),
1613 func HandleInviteToChat(cc *ClientConn, t *Transaction) (res []Transaction) {
1614 if !cc.Authorize(accessOpenChat) {
1615 return cc.NewErrReply(t, "You are not allowed to request private chat.")
1619 targetID := t.GetField(FieldUserID).Data
1620 chatID := t.GetField(FieldChatID).Data
1622 return []Transaction{
1626 NewField(FieldChatID, chatID),
1627 NewField(FieldUserName, cc.UserName),
1628 NewField(FieldUserID, cc.ID[:]),
1632 NewField(FieldChatID, chatID),
1633 NewField(FieldUserName, cc.UserName),
1634 NewField(FieldUserID, cc.ID[:]),
1635 NewField(FieldUserIconID, cc.Icon),
1636 NewField(FieldUserFlags, cc.Flags[:]),
1641 func HandleRejectChatInvite(cc *ClientConn, t *Transaction) (res []Transaction) {
1642 chatID := [4]byte(t.GetField(FieldChatID).Data)
1643 privChat := cc.Server.PrivateChats[chatID]
1645 for _, c := range privChat.ClientConn {
1650 NewField(FieldChatID, chatID[:]),
1651 NewField(FieldData, append(cc.UserName, []byte(" declined invitation to chat")...)),
1659 // HandleJoinChat is sent from a v1.8+ Hotline client when the joins a private chat
1660 // Fields used in the reply:
1661 // * 115 Chat subject
1662 // * 300 User Name with info (Optional)
1663 // * 300 (more user names with info)
1664 func HandleJoinChat(cc *ClientConn, t *Transaction) (res []Transaction) {
1665 chatID := t.GetField(FieldChatID).Data
1667 privChat := cc.Server.PrivateChats[[4]byte(chatID)]
1669 // Send TranNotifyChatChangeUser to current members of the chat to inform of new user
1670 for _, c := range privChat.ClientConn {
1673 TranNotifyChatChangeUser,
1675 NewField(FieldChatID, chatID),
1676 NewField(FieldUserName, cc.UserName),
1677 NewField(FieldUserID, cc.ID[:]),
1678 NewField(FieldUserIconID, cc.Icon),
1679 NewField(FieldUserFlags, cc.Flags[:]),
1684 privChat.ClientConn[cc.ID] = cc
1686 replyFields := []Field{NewField(FieldChatSubject, []byte(privChat.Subject))}
1687 for _, c := range privChat.ClientConn {
1688 b, err := io.ReadAll(&User{
1692 Name: string(c.UserName),
1697 replyFields = append(replyFields, NewField(FieldUsernameWithInfo, b))
1700 res = append(res, cc.NewReply(t, replyFields...))
1704 // HandleLeaveChat is sent from a v1.8+ Hotline client when the user exits a private chat
1705 // Fields used in the request:
1706 // - 114 FieldChatID
1708 // Reply is not expected.
1709 func HandleLeaveChat(cc *ClientConn, t *Transaction) (res []Transaction) {
1710 chatID := t.GetField(FieldChatID).Data
1712 privChat, ok := cc.Server.PrivateChats[[4]byte(chatID)]
1717 delete(privChat.ClientConn, cc.ID)
1719 // Notify members of the private chat that the user has left
1720 for _, c := range privChat.ClientConn {
1723 TranNotifyChatDeleteUser,
1725 NewField(FieldChatID, chatID),
1726 NewField(FieldUserID, cc.ID[:]),
1734 // HandleSetChatSubject is sent from a v1.8+ Hotline client when the user sets a private chat subject
1735 // Fields used in the request:
1737 // * 115 Chat subject
1738 // Reply is not expected.
1739 func HandleSetChatSubject(cc *ClientConn, t *Transaction) (res []Transaction) {
1740 chatID := t.GetField(FieldChatID).Data
1742 privChat := cc.Server.PrivateChats[[4]byte(chatID)]
1743 privChat.Subject = string(t.GetField(FieldChatSubject).Data)
1745 for _, c := range privChat.ClientConn {
1748 TranNotifyChatSubject,
1750 NewField(FieldChatID, chatID),
1751 NewField(FieldChatSubject, t.GetField(FieldChatSubject).Data),
1759 // HandleMakeAlias makes a file alias using the specified path.
1760 // Fields used in the request:
1763 // 212 File new path Destination path
1765 // Fields used in the reply:
1767 func HandleMakeAlias(cc *ClientConn, t *Transaction) (res []Transaction) {
1768 if !cc.Authorize(accessMakeAlias) {
1769 return cc.NewErrReply(t, "You are not allowed to make aliases.")
1771 fileName := t.GetField(FieldFileName).Data
1772 filePath := t.GetField(FieldFilePath).Data
1773 fileNewPath := t.GetField(FieldFileNewPath).Data
1775 fullFilePath, err := readPath(cc.Server.Config.FileRoot, filePath, fileName)
1780 fullNewFilePath, err := readPath(cc.Server.Config.FileRoot, fileNewPath, fileName)
1785 cc.logger.Debug("Make alias", "src", fullFilePath, "dst", fullNewFilePath)
1787 if err := cc.Server.FS.Symlink(fullFilePath, fullNewFilePath); err != nil {
1788 return cc.NewErrReply(t, "Error creating alias")
1791 res = append(res, cc.NewReply(t))
1795 // HandleDownloadBanner handles requests for a new banner from the server
1796 // Fields used in the request:
1798 // Fields used in the reply:
1799 // 107 FieldRefNum Used later for transfer
1800 // 108 FieldTransferSize Size of data to be downloaded
1801 func HandleDownloadBanner(cc *ClientConn, t *Transaction) (res []Transaction) {
1802 ft := cc.newFileTransfer(bannerDownload, []byte{}, []byte{}, make([]byte, 4))
1803 binary.BigEndian.PutUint32(ft.TransferSize, uint32(len(cc.Server.banner)))
1805 return append(res, cc.NewReply(t,
1806 NewField(FieldRefNum, ft.refNum[:]),
1807 NewField(FieldTransferSize, ft.TransferSize),