diff --git a/Dockerfile b/Dockerfile index fa8a2f2..b05b0c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -118,7 +118,7 @@ COPY src/go.mod /tmp/signal-cli-rest-api-src/ COPY src/go.sum /tmp/signal-cli-rest-api-src/ # build signal-cli-rest-api -RUN cd /tmp/signal-cli-rest-api-src && swag init && go build +RUN cd /tmp/signal-cli-rest-api-src && swag init && go test ./client -v && go build # build supervisorctl_config_creator RUN cd /tmp/signal-cli-rest-api-src/scripts && go build -o jsonrpc2-helper diff --git a/src/api/api.go b/src/api/api.go index a7be4f8..58c3a44 100644 --- a/src/api/api.go +++ b/src/api/api.go @@ -69,7 +69,7 @@ type RegisterNumberRequest struct { } type UnregisterNumberRequest struct { - DeleteAccount bool `json:"delete_account" example:"false"` + DeleteAccount bool `json:"delete_account" example:"false"` DeleteLocalData bool `json:"delete_local_data" example:"false"` } @@ -88,7 +88,7 @@ type SendMessageV1 struct { Number string `json:"number"` Recipients []string `json:"recipients"` Message string `json:"message"` - Base64Attachment string `json:"base64_attachment"` + Base64Attachment string `json:"base64_attachment" example:"'' OR 'data:;base64,' OR 'data:;filename=;base64,'"` IsGroup bool `json:"is_group"` } @@ -96,7 +96,7 @@ type SendMessageV2 struct { Number string `json:"number"` Recipients []string `json:"recipients"` Message string `json:"message"` - Base64Attachments []string `json:"base64_attachments"` + Base64Attachments []string `json:"base64_attachments" example:",data:;base64,data:;filename=;base64"` } type TypingIndicatorRequest struct { diff --git a/src/client/attachment.go b/src/client/attachment.go new file mode 100644 index 0000000..a51d902 --- /dev/null +++ b/src/client/attachment.go @@ -0,0 +1,169 @@ +package client + +import ( + "encoding/base64" + "errors" + "os" + "reflect" + "strings" + + "github.com/gabriel-vasile/mimetype" + uuid "github.com/gofrs/uuid" +) + +type AttachmentEntry struct { + MimeInfo string + FileName string + DirName string + Base64 string + FilePath string + attachmentTmpDir string +} + +func NewAttachmentEntry(attachmentData string, attachmentTmpDir string) *AttachmentEntry { + attachmentEntry := AttachmentEntry{ + MimeInfo: "", + FileName: "", + DirName: "", + Base64: "", + FilePath: "", + attachmentTmpDir: attachmentTmpDir, + } + + attachmentEntry.extractMetaData(attachmentData) + + return &attachmentEntry +} + +func (attachmentEntry *AttachmentEntry) extractMetaData(attachmentData string) { + base64FlagIndex := strings.LastIndex(attachmentData, "base64,") + + if !strings.Contains(attachmentData, "data:") || base64FlagIndex == -1 { + attachmentEntry.Base64 = attachmentData + return + } + + attachmentEntry.Base64 = attachmentData[base64FlagIndex+len("base64,"):] + metaDataKeys := map[string]string{ + "data:": "MimeInfo", + "filename=": "FileName", + } + + for _, metaDataLineItem := range strings.Split(attachmentData[:base64FlagIndex-1], ";") { + for metaDataKey, metaDataFieldName := range metaDataKeys { + flagIndex := strings.Index(metaDataLineItem, metaDataKey) + + if flagIndex != 0 { + continue + } + + attachmentEntry.setFieldValueByName(metaDataFieldName, metaDataLineItem[len(metaDataKey):]) + } + } +} + +func (attachmentEntry *AttachmentEntry) storeBase64AsTemporaryFile() error { + if strings.Compare(attachmentEntry.Base64, "") == 0 { + return errors.New("The base64 data does not exist.") + } + + dec, err := base64.StdEncoding.DecodeString(attachmentEntry.Base64) + if err != nil { + return err + } + + // if no custom filename + if strings.Compare(attachmentEntry.FileName, "") == 0 { + mimeType := mimetype.Detect(dec) + + fileNameUuid, err := uuid.NewV4() + if err != nil { + return err + } + attachmentEntry.FileName = fileNameUuid.String() + mimeType.Extension() + } + + dirNameUuid, err := uuid.NewV4() + if err != nil { + return err + } + + attachmentEntry.DirName = dirNameUuid.String() + dirPath := attachmentEntry.attachmentTmpDir + attachmentEntry.DirName + if err := os.Mkdir(dirPath, os.ModePerm); err != nil { + return err + } + + attachmentEntry.FilePath = dirPath + string(os.PathSeparator) + attachmentEntry.FileName + + f, err := os.Create(attachmentEntry.FilePath) + if err != nil { + return err + } + defer f.Close() + + if _, err := f.Write(dec); err != nil { + attachmentEntry.cleanUp() + return err + } + if err := f.Sync(); err != nil { + attachmentEntry.cleanUp() + return err + } + f.Close() + + return nil +} + +func (attachmentEntry *AttachmentEntry) cleanUp() { + if strings.Compare(attachmentEntry.FilePath, "") != 0 { + os.Remove(attachmentEntry.FilePath) + } + + if strings.Compare(attachmentEntry.DirName, "") != 0 { + dirPath := attachmentEntry.attachmentTmpDir + attachmentEntry.DirName + os.Remove(dirPath) + } +} + +func (attachmentEntry *AttachmentEntry) setFieldValueByName(fieldName string, fieldValue string) { + reflectPointer := reflect.ValueOf(attachmentEntry) + reflectStructure := reflectPointer.Elem() + + if reflectStructure.Kind() != reflect.Struct { + return + } + + field := reflectStructure.FieldByName(fieldName) + if !field.IsValid() { + return + } + + if !field.CanSet() || field.Kind() != reflect.String { + return + } + + field.SetString(fieldValue) +} + +func (attachmentEntry *AttachmentEntry) isWithMetaData() bool { + return len(attachmentEntry.MimeInfo) > 0 && len(attachmentEntry.Base64) > 0 +} + +func (attachmentEntry *AttachmentEntry) toDataForSignal() string { + if len(attachmentEntry.FilePath) > 0 { + return attachmentEntry.FilePath + } + + if !attachmentEntry.isWithMetaData() { + return attachmentEntry.Base64 + } + + result := "data:" + attachmentEntry.MimeInfo + + if len(attachmentEntry.FileName) > 0 { + result = result + ";filename=" + attachmentEntry.FileName + } + + return result + ";base64," + attachmentEntry.Base64 +} diff --git a/src/client/attachment_test.go b/src/client/attachment_test.go new file mode 100644 index 0000000..8e4e711 --- /dev/null +++ b/src/client/attachment_test.go @@ -0,0 +1,107 @@ +package client + +import ( + "flag" + "os" + "strings" + "testing" +) + +func Test_Attachment_ExtractMetadata_ShouldPrepareDataFor_toDataForSignal(t *testing.T) { + testCases := []struct { + nameTest string + inputData string + resultIsWithMetaData bool + base64Expected string + base64Valid bool + fileNameExpected string + mimeInfoExpected string + toDataForSignal string + }{ + { + "BC base64 - compatibility", "MTIzNDU=", false, "MTIzNDU=", true, "", "", "MTIzNDU=", + }, + { + "+base64 -data -filename", "base64,MTIzNDU=", false, "base64,MTIzNDU=", false, "", "", "base64,MTIzNDU=", + }, + { + "+base64 +data -filename", "data:someData;base64,MTIzNDU=", true, "MTIzNDU=", true, "", "someData", "data:someData;base64,MTIzNDU=", + }, + { + "+base64 -data +filename", "filename=file.name;base64,MTIzNDU=", false, "filename=file.name;base64,MTIzNDU=", false, "", "", "filename=file.name;base64,MTIzNDU=", + }, + { + "+base64 +data +filename", "data:someData;filename=file.name;base64,MTIzNDU=", true, "MTIzNDU=", true, "file.name", "someData", "data:someData;filename=file.name;base64,MTIzNDU=", + }, + { + "-base64 -data -filename", "INVALIDMTIzNDU=", false, "INVALIDMTIzNDU=", false, "", "", "INVALIDMTIzNDU=", + }, + { + "-base64 +data -filename", "data:someData;INVALIDMTIzNDU=", false, "data:someData;INVALIDMTIzNDU=", false, "", "", "data:someData;INVALIDMTIzNDU=", + }, + { + "-base64 -data +filename", "filename=file.name;INVALIDMTIzNDU=", false, "filename=file.name;INVALIDMTIzNDU=", false, "", "", "filename=file.name;INVALIDMTIzNDU=", + }, + { + "-base64 +data +filename", "data:someData;filename=file.name;INVALIDMTIzNDU=", false, "data:someData;filename=file.name;INVALIDMTIzNDU=", false, "", "", "data:someData;filename=file.name;INVALIDMTIzNDU=", + }, + } + + attachmentTmp := flag.String("attachment-tmp-dir", string(os.PathSeparator)+"tmp"+string(os.PathSeparator), "Attachment tmp directory") + + for _, tt := range testCases { + t.Run(tt.nameTest, func(t *testing.T) { + attachmentEntry := NewAttachmentEntry(tt.inputData, *attachmentTmp) + + if attachmentEntry.isWithMetaData() != tt.resultIsWithMetaData { + t.Errorf("isWithMetaData() got \"%v\", want \"%v\"", attachmentEntry.isWithMetaData(), tt.resultIsWithMetaData) + } + + if strings.Compare(attachmentEntry.Base64, tt.base64Expected) != 0 { + t.Errorf("Base64 got \"%v\", want \"%v\"", attachmentEntry.Base64, tt.base64Expected) + } + + if strings.Compare(attachmentEntry.FileName, tt.fileNameExpected) != 0 { + t.Errorf("FileName got \"%v\", want \"%v\"", attachmentEntry.FileName, tt.fileNameExpected) + } + + if strings.Compare(attachmentEntry.MimeInfo, tt.mimeInfoExpected) != 0 { + t.Errorf("MimeInfo got \"%v\", want \"%v\"", attachmentEntry.MimeInfo, tt.mimeInfoExpected) + } + + if strings.Compare(attachmentEntry.toDataForSignal(), tt.toDataForSignal) != 0 { + t.Errorf("toDataForSignal() got \"%v\", want \"%v\"", attachmentEntry.toDataForSignal(), tt.toDataForSignal) + } + + err := attachmentEntry.storeBase64AsTemporaryFile() + if err != nil && tt.base64Valid { + t.Error("storeBase64AsTemporaryFile error: %w", err) + return + } + + info, err := os.Stat(attachmentEntry.FilePath) + if os.IsNotExist(err) && tt.base64Valid { + t.Error("file not exists after storeBase64AsTemporaryFile: %w", err) + return + } + if (info == nil || info.IsDir()) && tt.base64Valid { + t.Error("is not a file by path after storeBase64AsTemporaryFile") + t.Error(attachmentEntry) + return + } + + attachmentEntry.cleanUp() + + info, err = os.Stat(attachmentEntry.FilePath) + if info != nil && !os.IsNotExist(err) && tt.base64Valid { + t.Error("no info or file exists after cleanUp") + return + } + info, err = os.Stat(*attachmentTmp + attachmentEntry.DirName) + if info != nil && !os.IsNotExist(err) && tt.base64Valid { + t.Error("dir exists after cleanUp") + return + } + }) + } +} diff --git a/src/client/client.go b/src/client/client.go index 96db759..844a8e4 100644 --- a/src/client/client.go +++ b/src/client/client.go @@ -4,15 +4,14 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "io/ioutil" "os" "path/filepath" "strconv" "strings" - "fmt" securejoin "github.com/cyphar/filepath-securejoin" - "github.com/gabriel-vasile/mimetype" "github.com/h2non/filetype" uuid "github.com/gofrs/uuid" @@ -134,6 +133,12 @@ func cleanupTmpFiles(paths []string) { } } +func cleanupAttachmentEntries(attachmentEntries []AttachmentEntry) { + for _, attachmentEntry := range attachmentEntries { + attachmentEntry.cleanUp() + } +} + func convertInternalGroupIdToGroupId(internalId string) string { return groupPrefix + base64.StdEncoding.EncodeToString([]byte(internalId)) } @@ -194,7 +199,6 @@ func getContainerId() (string, error) { return containerId, nil } - func ConvertGroupIdToInternalGroupId(id string) (string, error) { groupIdWithoutPrefix := strings.TrimPrefix(id, groupPrefix) @@ -300,39 +304,17 @@ func (s *SignalClient) send(number string, message string, groupId = string(grpId) } - attachmentTmpPaths := []string{} + attachmentEntries := []AttachmentEntry{} for _, base64Attachment := range base64Attachments { - u, err := uuid.NewV4() + attachmentEntry := NewAttachmentEntry(base64Attachment, s.attachmentTmpDir) + + err := attachmentEntry.storeBase64AsTemporaryFile() if err != nil { + cleanupAttachmentEntries(attachmentEntries) return nil, err } - dec, err := base64.StdEncoding.DecodeString(base64Attachment) - if err != nil { - return nil, err - } - - mimeType := mimetype.Detect(dec) - - attachmentTmpPath := s.attachmentTmpDir + u.String() + mimeType.Extension() - attachmentTmpPaths = append(attachmentTmpPaths, attachmentTmpPath) - - f, err := os.Create(attachmentTmpPath) - if err != nil { - return nil, err - } - defer f.Close() - - if _, err := f.Write(dec); err != nil { - cleanupTmpFiles(attachmentTmpPaths) - return nil, err - } - if err := f.Sync(); err != nil { - cleanupTmpFiles(attachmentTmpPaths) - return nil, err - } - - f.Close() + attachmentEntries = append(attachmentEntries, *attachmentEntry) } if s.signalCliMode == JsonRpc { @@ -354,13 +336,13 @@ func (s *SignalClient) send(number string, message string, } else { request.Recipients = recipients } - if len(attachmentTmpPaths) > 0 { - request.Attachments = append(request.Attachments, attachmentTmpPaths...) + for _, attachmentEntry := range attachmentEntries { + request.Attachments = append(request.Attachments, attachmentEntry.toDataForSignal()) } rawData, err := jsonRpc2Client.getRaw("send", request) if err != nil { - cleanupTmpFiles(attachmentTmpPaths) + cleanupAttachmentEntries(attachmentEntries) return nil, err } @@ -379,14 +361,16 @@ func (s *SignalClient) send(number string, message string, cmd = append(cmd, []string{"-g", groupId}...) } - if len(attachmentTmpPaths) > 0 { + if len(attachmentEntries) > 0 { cmd = append(cmd, "-a") - cmd = append(cmd, attachmentTmpPaths...) + for _, attachmentEntry := range attachmentEntries { + cmd = append(cmd, attachmentEntry.toDataForSignal()) + } } rawData, err := s.cliClient.Execute(true, cmd, message) if err != nil { - cleanupTmpFiles(attachmentTmpPaths) + cleanupAttachmentEntries(attachmentEntries) if strings.Contains(err.Error(), signalCliV2GroupError) { return nil, errors.New("Cannot send message to group - please first update your profile.") } @@ -394,12 +378,12 @@ func (s *SignalClient) send(number string, message string, } resp.Timestamp, err = strconv.ParseInt(strings.TrimSuffix(rawData, "\n"), 10, 64) if err != nil { - cleanupTmpFiles(attachmentTmpPaths) + cleanupAttachmentEntries(attachmentEntries) return nil, err } } - cleanupTmpFiles(attachmentTmpPaths) + cleanupAttachmentEntries(attachmentEntries) return &resp, nil } @@ -444,7 +428,7 @@ func (s *SignalClient) UnregisterNumber(number string, deleteAccount bool, delet command := []string{"--config", s.signalCliConfig, "-a", number, "deleteLocalAccountData"} _, err2 := s.cliClient.Execute(true, command, "") if (err2 != nil) && (err != nil) { - err = fmt.Errorf("%w (%w)", err, err2) + err = fmt.Errorf("%w (%s)", err, err2.Error()) } else if (err2 != nil) && (err == nil) { err = err2 } diff --git a/src/docs/docs.go b/src/docs/docs.go index 2b67e29..926e2bb 100644 --- a/src/docs/docs.go +++ b/src/docs/docs.go @@ -241,7 +241,9 @@ var doc = `{ } ], "responses": { - "200": {}, + "200": { + "description": "" + }, "400": { "description": "Bad Request", "schema": { @@ -281,7 +283,9 @@ var doc = `{ } ], "responses": { - "204": {}, + "204": { + "description": "" + }, "400": { "description": "Bad Request", "schema": { @@ -323,7 +327,9 @@ var doc = `{ } ], "responses": { - "204": {}, + "204": { + "description": "" + }, "400": { "description": "Bad Request", "schema": { @@ -365,7 +371,9 @@ var doc = `{ } ], "responses": { - "204": {}, + "204": { + "description": "" + }, "400": { "description": "Bad Request", "schema": { @@ -1200,7 +1208,9 @@ var doc = `{ } ], "responses": { - "201": {}, + "201": { + "description": "" + }, "400": { "description": "Bad Request", "schema": { @@ -1472,7 +1482,9 @@ var doc = `{ } ], "responses": { - "204": {}, + "204": { + "description": "" + }, "400": { "description": "Bad Request", "schema": { @@ -1686,7 +1698,8 @@ var doc = `{ "type": "object", "properties": { "base64_attachment": { - "type": "string" + "type": "string", + "example": "'\u003cBASE64 ENCODED DATA\u003e' OR 'data:\u003cMIME-TYPE\u003e;base64,\u003cBASE64 ENCODED DATA\u003e' OR 'data:\u003cMIME-TYPE\u003e;filename=\u003cFILENAME\u003e;base64,\u003cBASE64 ENCODED DATA\u003e'" }, "is_group": { "type": "boolean" @@ -1712,7 +1725,12 @@ var doc = `{ "type": "array", "items": { "type": "string" - } + }, + "example": [ + "\u003cBASE64 ENCODED DATA\u003e", + "data:\u003cMIME-TYPE\u003e;base64\u003ccomma\u003e\u003cBASE64 ENCODED DATA\u003e", + "data:\u003cMIME-TYPE\u003e;filename=\u003cFILENAME\u003e;base64\u003ccomma\u003e\u003cBASE64 ENCODED DATA\u003e" + ] }, "message": { "type": "string" diff --git a/src/docs/swagger.json b/src/docs/swagger.json index 1befde4..5d59150 100644 --- a/src/docs/swagger.json +++ b/src/docs/swagger.json @@ -225,7 +225,9 @@ } ], "responses": { - "200": {}, + "200": { + "description": "" + }, "400": { "description": "Bad Request", "schema": { @@ -265,7 +267,9 @@ } ], "responses": { - "204": {}, + "204": { + "description": "" + }, "400": { "description": "Bad Request", "schema": { @@ -307,7 +311,9 @@ } ], "responses": { - "204": {}, + "204": { + "description": "" + }, "400": { "description": "Bad Request", "schema": { @@ -349,7 +355,9 @@ } ], "responses": { - "204": {}, + "204": { + "description": "" + }, "400": { "description": "Bad Request", "schema": { @@ -1184,7 +1192,9 @@ } ], "responses": { - "201": {}, + "201": { + "description": "" + }, "400": { "description": "Bad Request", "schema": { @@ -1456,7 +1466,9 @@ } ], "responses": { - "204": {}, + "204": { + "description": "" + }, "400": { "description": "Bad Request", "schema": { @@ -1670,7 +1682,8 @@ "type": "object", "properties": { "base64_attachment": { - "type": "string" + "type": "string", + "example": "'\u003cBASE64 ENCODED DATA\u003e' OR 'data:\u003cMIME-TYPE\u003e;base64,\u003cBASE64 ENCODED DATA\u003e' OR 'data:\u003cMIME-TYPE\u003e;filename=\u003cFILENAME\u003e;base64,\u003cBASE64 ENCODED DATA\u003e'" }, "is_group": { "type": "boolean" @@ -1696,7 +1709,12 @@ "type": "array", "items": { "type": "string" - } + }, + "example": [ + "\u003cBASE64 ENCODED DATA\u003e", + "data:\u003cMIME-TYPE\u003e;base64\u003ccomma\u003e\u003cBASE64 ENCODED DATA\u003e", + "data:\u003cMIME-TYPE\u003e;filename=\u003cFILENAME\u003e;base64\u003ccomma\u003e\u003cBASE64 ENCODED DATA\u003e" + ] }, "message": { "type": "string" diff --git a/src/docs/swagger.yaml b/src/docs/swagger.yaml index 67739a2..4e710bb 100644 --- a/src/docs/swagger.yaml +++ b/src/docs/swagger.yaml @@ -106,6 +106,9 @@ definitions: api.SendMessageV1: properties: base64_attachment: + example: ''''' OR ''data:;base64,'' OR ''data:;filename=;base64,''' type: string is_group: type: boolean @@ -121,6 +124,10 @@ definitions: api.SendMessageV2: properties: base64_attachments: + example: + - + - data:;base64 + - data:;filename=;base64 items: type: string type: array @@ -389,7 +396,8 @@ paths: produces: - application/json responses: - "200": {} + "200": + description: "" "400": description: Bad Request schema: @@ -416,7 +424,8 @@ paths: produces: - application/json responses: - "204": {} + "204": + description: "" "400": description: Bad Request schema: @@ -444,19 +453,22 @@ paths: produces: - application/json responses: - "204": {} + "204": + description: "" "400": description: Bad Request schema: $ref: '#/definitions/api.Error' - summary: Updates the info associated to a number on the contact list. If the contact doesn’t exist yet, it will be added. + summary: Updates the info associated to a number on the contact list. If the + contact doesn’t exist yet, it will be added. tags: - Contacts /v1/devices/{number}: post: consumes: - application/json - description: Links another device to this device. Only works, if this is the master device. + description: Links another device to this device. Only works, if this is the + master device. parameters: - description: Registered Phone Number in: path @@ -472,7 +484,8 @@ paths: produces: - application/json responses: - "204": {} + "204": + description: "" "400": description: Bad Request schema: @@ -844,7 +857,8 @@ paths: - Identities /v1/identities/{number}/trust/{numberToTrust}: put: - description: Trust an identity. When 'trust_all_known_keys' is set to' true', all known keys of this user are trusted. **This is only recommended for testing.** + description: Trust an identity. When 'trust_all_known_keys' is set to' true', + all known keys of this user are trusted. **This is only recommended for testing.** parameters: - description: Input Data in: body @@ -979,7 +993,9 @@ paths: get: consumes: - application/json - description: Receives Signal Messages from the Signal Network. If you are running the docker container in normal/native mode, this is a GET endpoint. In json-rpc mode this is a websocket endpoint. + description: Receives Signal Messages from the Signal Network. If you are running + the docker container in normal/native mode, this is a GET endpoint. In json-rpc + mode this is a websocket endpoint. parameters: - description: Registered Phone Number in: path @@ -1025,7 +1041,8 @@ paths: produces: - application/json responses: - "201": {} + "201": + description: "" "400": description: Bad Request schema: @@ -1072,7 +1089,8 @@ paths: get: consumes: - application/json - description: Check if one or more phone numbers are registered with the Signal Service. + description: Check if one or more phone numbers are registered with the Signal + Service. parameters: - collectionFormat: multi description: Numbers to check @@ -1190,7 +1208,9 @@ paths: post: consumes: - application/json - description: Disables push support for this device. **WARNING:** If *delete_account* is set to *true*, the account will be deleted from the Signal Server. This cannot be undone without loss. + description: Disables push support for this device. **WARNING:** If *delete_account* + is set to *true*, the account will be deleted from the Signal Server. This + cannot be undone without loss. parameters: - description: Registered Phone Number in: path @@ -1205,7 +1225,8 @@ paths: produces: - application/json responses: - "204": {} + "204": + description: "" "400": description: Bad Request schema: