X-Git-Url: https://git.r.bdr.sh/rbdr/mobius/blobdiff_plain/a8e89ca4e65f5db3ee6061ede68aade814b4d75c..d9bc63a10d0978d9a5222cf7be74044e55f409b7:/hotline/field.go diff --git a/hotline/field.go b/hotline/field.go index 4d2962b..4c760d5 100644 --- a/hotline/field.go +++ b/hotline/field.go @@ -1,102 +1,193 @@ package hotline import ( + "bufio" + "bytes" "encoding/binary" - "github.com/jhalter/mobius/concat" + "errors" + "io" + "slices" ) // List of Hotline protocol field types taken from the official 1.9 protocol document -const ( - FieldError = 100 - FieldData = 101 - FieldUserName = 102 - FieldUserID = 103 - FieldUserIconID = 104 - FieldUserLogin = 105 - FieldUserPassword = 106 - FieldRefNum = 107 - FieldTransferSize = 108 - FieldChatOptions = 109 - FieldUserAccess = 110 - FieldUserAlias = 111 // TODO: implement - FieldUserFlags = 112 - FieldOptions = 113 - FieldChatID = 114 - FieldChatSubject = 115 - FieldWaitingCount = 116 - FieldBannerType = 152 - FieldNoServerAgreement = 152 - FieldVersion = 160 - FieldCommunityBannerID = 161 - FieldServerName = 162 - FieldFileNameWithInfo = 200 - FieldFileName = 201 - FieldFilePath = 202 - FieldFileResumeData = 203 - FieldFileTransferOptions = 204 - FieldFileTypeString = 205 - FieldFileCreatorString = 206 - FieldFileSize = 207 - FieldFileCreateDate = 208 - FieldFileModifyDate = 209 - FieldFileComment = 210 - FieldFileNewName = 211 - FieldFileNewPath = 212 - FieldFileType = 213 - FieldQuotingMsg = 214 - FieldAutomaticResponse = 215 - FieldFolderItemCount = 220 - FieldUsernameWithInfo = 300 - FieldNewsArtListData = 321 - FieldNewsCatName = 322 - FieldNewsCatListData15 = 323 - FieldNewsPath = 325 - FieldNewsArtID = 326 - FieldNewsArtDataFlav = 327 - FieldNewsArtTitle = 328 - FieldNewsArtPoster = 329 - FieldNewsArtDate = 330 - FieldNewsArtPrevArt = 331 - FieldNewsArtNextArt = 332 - FieldNewsArtData = 333 - FieldNewsArtFlags = 334 // TODO: what is this used for? - FieldNewsArtParentArt = 335 - FieldNewsArt1stChildArt = 336 - FieldNewsArtRecurseDel = 337 // TODO: implement news article recusive deletion +var ( + FieldError = [2]byte{0x00, 0x64} // 100 + FieldData = [2]byte{0x00, 0x65} // 101 + FieldUserName = [2]byte{0x00, 0x66} // 102 + FieldUserID = [2]byte{0x00, 0x67} // 103 + FieldUserIconID = [2]byte{0x00, 0x68} // 104 + FieldUserLogin = [2]byte{0x00, 0x69} // 105 + FieldUserPassword = [2]byte{0x00, 0x6A} // 106 + FieldRefNum = [2]byte{0x00, 0x6B} // 107 + FieldTransferSize = [2]byte{0x00, 0x6C} // 108 + FieldChatOptions = [2]byte{0x00, 0x6D} // 109 + FieldUserAccess = [2]byte{0x00, 0x6E} // 110 + FieldUserFlags = [2]byte{0x00, 0x70} // 112 + FieldOptions = [2]byte{0x00, 0x71} // 113 + FieldChatID = [2]byte{0x00, 0x72} // 114 + FieldChatSubject = [2]byte{0x00, 0x73} // 115 + FieldWaitingCount = [2]byte{0x00, 0x74} // 116 + FieldBannerType = [2]byte{0x00, 0x98} // 152 + FieldNoServerAgreement = [2]byte{0x00, 0x98} // 152 + FieldVersion = [2]byte{0x00, 0xA0} // 160 + FieldCommunityBannerID = [2]byte{0x00, 0xA1} // 161 + FieldServerName = [2]byte{0x00, 0xA2} // 162 + FieldFileNameWithInfo = [2]byte{0x00, 0xC8} // 200 + FieldFileName = [2]byte{0x00, 0xC9} // 201 + FieldFilePath = [2]byte{0x00, 0xCA} // 202 + FieldFileResumeData = [2]byte{0x00, 0xCB} // 203 + FieldFileTransferOptions = [2]byte{0x00, 0xCC} // 204 + FieldFileTypeString = [2]byte{0x00, 0xCD} // 205 + FieldFileCreatorString = [2]byte{0x00, 0xCE} // 206 + FieldFileSize = [2]byte{0x00, 0xCF} // 207 + FieldFileCreateDate = [2]byte{0x00, 0xD0} // 208 + FieldFileModifyDate = [2]byte{0x00, 0xD1} // 209 + FieldFileComment = [2]byte{0x00, 0xD2} // 210 + FieldFileNewName = [2]byte{0x00, 0xD3} // 211 + FieldFileNewPath = [2]byte{0x00, 0xD4} // 212 + FieldFileType = [2]byte{0x00, 0xD5} // 213 + FieldQuotingMsg = [2]byte{0x00, 0xD6} // 214 + FieldAutomaticResponse = [2]byte{0x00, 0xD7} // 215 + FieldFolderItemCount = [2]byte{0x00, 0xDC} // 220 + FieldUsernameWithInfo = [2]byte{0x01, 0x2C} // 300 + FieldNewsArtListData = [2]byte{0x01, 0x41} // 321 + FieldNewsCatName = [2]byte{0x01, 0x42} // 322 + FieldNewsCatListData15 = [2]byte{0x01, 0x43} // 323 + FieldNewsPath = [2]byte{0x01, 0x45} // 325 + FieldNewsArtID = [2]byte{0x01, 0x46} // 326 + FieldNewsArtDataFlav = [2]byte{0x01, 0x47} // 327 + FieldNewsArtTitle = [2]byte{0x01, 0x48} // 328 + FieldNewsArtPoster = [2]byte{0x01, 0x49} // 329 + FieldNewsArtDate = [2]byte{0x01, 0x4A} // 330 + FieldNewsArtPrevArt = [2]byte{0x01, 0x4B} // 331 + FieldNewsArtNextArt = [2]byte{0x01, 0x4C} // 332 + FieldNewsArtData = [2]byte{0x01, 0x4D} // 333 + FieldNewsArtParentArt = [2]byte{0x01, 0x4F} // 335 + FieldNewsArt1stChildArt = [2]byte{0x01, 0x50} // 336 + FieldNewsArtRecurseDel = [2]byte{0x01, 0x51} // 337 + + // These fields are documented, but seemingly unused. + // FieldUserAlias = [2]byte{0x00, 0x6F} // 111 + // FieldNewsArtFlags = [2]byte{0x01, 0x4E} // 334 ) type Field struct { - ID []byte // Type of field - FieldSize []byte // Size of the data part - Data []byte // Actual field content + Type [2]byte // Type of field + FieldSize [2]byte // Size of the data field + Data []byte // Field data + + readOffset int // Internal offset to track read progress } -type requiredField struct { - ID int - minLen int +func NewField(fieldType [2]byte, data []byte) Field { + f := Field{ + Type: fieldType, + Data: make([]byte, len(data)), + } + + // Copy instead of assigning to avoid data race when the field is read in another go routine. + copy(f.Data, data) + + binary.BigEndian.PutUint16(f.FieldSize[:], uint16(len(data))) + return f } -func NewField(id uint16, data []byte) Field { - idBytes := make([]byte, 2) - binary.BigEndian.PutUint16(idBytes, id) +// fieldScanner implements bufio.SplitFunc for parsing byte slices into complete tokens +func fieldScanner(data []byte, _ bool) (advance int, token []byte, err error) { + if len(data) < minFieldLen { + return 0, nil, nil + } + + // neededSize represents the length of bytes that are part of the field token. + neededSize := minFieldLen + int(binary.BigEndian.Uint16(data[2:4])) + if neededSize > len(data) { + return 0, nil, nil + } - bs := make([]byte, 2) - binary.BigEndian.PutUint16(bs, uint16(len(data))) + return neededSize, data[0:neededSize], nil +} - return Field{ - ID: idBytes, - FieldSize: bs, - Data: data, +// DecodeInt decodes the field bytes to an int. +// The official Hotline clients will send uint32s as 2 bytes if possible, but +// some third party clients such as Frogblast and Heildrun will always send 4 bytes +func (f *Field) DecodeInt() (int, error) { + switch len(f.Data) { + case 2: + return int(binary.BigEndian.Uint16(f.Data)), nil + case 4: + return int(binary.BigEndian.Uint32(f.Data)), nil } + + return 0, errors.New("unknown byte length") +} + +func (f *Field) DecodeObfuscatedString() string { + return string(encodeString(f.Data)) } -func (f Field) Payload() []byte { - return concat.Slices(f.ID, f.FieldSize, f.Data) +// DecodeNewsPath decodes the field data to a news path. +// Example News Path data for a Category nested under two Bundles: +// 00000000 00 03 00 00 10 54 6f 70 20 4c 65 76 65 6c 20 42 |.....Top Level B| +// 00000010 75 6e 64 6c 65 00 00 13 53 65 63 6f 6e 64 20 4c |undle...Second L| +// 00000020 65 76 65 6c 20 42 75 6e 64 6c 65 00 00 0f 4e 65 |evel Bundle...Ne| +// 00000030 73 74 65 64 20 43 61 74 65 67 6f 72 79 |sted Category| +func (f *Field) DecodeNewsPath() ([]string, error) { + if len(f.Data) == 0 { + return []string{}, nil + } + + pathCount := binary.BigEndian.Uint16(f.Data[0:2]) + + scanner := bufio.NewScanner(bytes.NewReader(f.Data[2:])) + scanner.Split(newsPathScanner) + + var paths []string + + for i := uint16(0); i < pathCount; i++ { + scanner.Scan() + paths = append(paths, scanner.Text()) + } + + return paths, nil +} + +// Read implements io.Reader for Field +func (f *Field) Read(p []byte) (int, error) { + buf := slices.Concat(f.Type[:], f.FieldSize[:], f.Data) + + if f.readOffset >= len(buf) { + return 0, io.EOF // All bytes have been read + } + + n := copy(p, buf[f.readOffset:]) + f.readOffset += n + + return n, nil +} + +// Write implements io.Writer for Field +func (f *Field) Write(p []byte) (int, error) { + if len(p) < minFieldLen { + return 0, errors.New("input slice too short") + } + + copy(f.Type[:], p[0:2]) + copy(f.FieldSize[:], p[2:4]) + + dataSize := int(binary.BigEndian.Uint16(f.FieldSize[:])) + if len(p) < minFieldLen+dataSize { + return 0, errors.New("input slice too short for data size") + } + + f.Data = make([]byte, dataSize) + copy(f.Data, p[4:4+dataSize]) + + return minFieldLen + dataSize, nil } -func getField(id int, fields *[]Field) *Field { +func getField(id [2]byte, fields *[]Field) *Field { for _, field := range *fields { - if id == int(binary.BigEndian.Uint16(field.ID)) { + if id == field.Type { return &field } }