diff --git a/src/api/account_identifier_test.go b/src/api/account_identifier_test.go new file mode 100644 index 0000000..2464d3d --- /dev/null +++ b/src/api/account_identifier_test.go @@ -0,0 +1,57 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/bbernhard/signal-cli-rest-api/utils" + "github.com/gin-gonic/gin" +) + +func TestAccountIdentifierFromParam(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("e164", func(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Params = gin.Params{{Key: "number", Value: "%2B12025550123"}} + typ, value, err := accountIdentifierFromParam(c) + if err != nil { + t.Fatal(err) + } + if typ != utils.IdentifierE164 || value != "+12025550123" { + t.Fatalf("got %v %q", typ, value) + } + flag, selected := utils.SignalCliAccountSelector(typ, value) + if flag != "-a" || selected != "+12025550123" { + t.Fatalf("selector %s %s", flag, selected) + } + }) + + t.Run("aci", func(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Params = gin.Params{{Key: "number", Value: "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE"}} + typ, value, err := accountIdentifierFromParam(c) + if err != nil { + t.Fatal(err) + } + if typ != utils.IdentifierACI || value != "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" { + t.Fatalf("got %v %q", typ, value) + } + }) + + t.Run("invalid", func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "number", Value: "not-an-account"}} + c.Request = httptest.NewRequest(http.MethodPost, "/v1/register/not-an-account", strings.NewReader("{}")) + NewApi(nil).RegisterNumber(c) + if w.Code != 400 { + t.Fatalf("status = %d, body %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "invalid account identifier") { + t.Fatalf("body = %s", w.Body.String()) + } + }) +} diff --git a/src/api/api.go b/src/api/api.go index 26a9acc..465fa03 100644 --- a/src/api/api.go +++ b/src/api/api.go @@ -118,7 +118,7 @@ type Receipt struct { } type SendMessageV1 struct { - Number string `json:"number"` + Number string `json:"number"` // Account identifier (E.164 phone number or ACI UUID) Recipients []string `json:"recipients"` Message string `json:"message"` Base64Attachment string `json:"base64_attachment,omitempty" example:"'' OR 'data:;base64,' OR 'data:;filename=;base64,'"` @@ -126,7 +126,7 @@ type SendMessageV1 struct { } type SendMessageV2 struct { - Number string `json:"number"` + Number string `json:"number"` // Account identifier (E.164 phone number or ACI UUID) Recipients []string `json:"recipients"` Recipient string `json:"recipient,omitempty" swaggerignore:"true"` //some REST API consumers (like the Synology NAS) do not support an array as recipients, so we provide this string parameter here as backup. In order to not confuse anyone, the parameter won't be exposed in the Swagger UI (most users are fine with the recipients parameter). Message string `json:"message"` @@ -270,6 +270,14 @@ func NewApi(signalClient *client.SignalClient) *Api { } } +func accountIdentifierFromParam(c *gin.Context) (utils.IdentifierType, string, error) { + raw, err := url.PathUnescape(c.Param("number")) + if err != nil { + return 0, "", errors.New("Couldn't process request - malformed number") + } + return utils.ParseAccountIdentifier(raw) +} + // @Summary Lists general information about the API // @Tags General // @Description Returns the supported API versions and the internal build nr @@ -287,13 +295,13 @@ func (a *Api) About(c *gin.Context) { // @Produce json // @Success 201 // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param data body RegisterNumberRequest false "Additional Settings" // @Router /v1/register/{number} [post] func (a *Api) RegisterNumber(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -339,13 +347,13 @@ func (a *Api) RegisterNumber(c *gin.Context) { // @Produce json // @Success 204 // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param data body UnregisterNumberRequest false "Additional Settings" // @Router /v1/unregister/{number} [post] func (a *Api) UnregisterNumber(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -378,15 +386,15 @@ func (a *Api) UnregisterNumber(c *gin.Context) { // @Description Delete all local data for the specified account. Only use this after unregistering the account or after removing a linked device. // @Accept json // @Produce json -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param data body DeleteLocalAccountDataRequest false "Cleanup options" // @Success 204 // @Failure 400 {object} Error // @Router /v1/devices/{number}/local-data [delete] func (a *Api) DeleteLocalAccountData(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -417,14 +425,14 @@ func (a *Api) DeleteLocalAccountData(c *gin.Context) { // @Produce json // @Success 201 {string} string "OK" // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param data body VerifyNumberSettings false "Additional Settings" // @Param token path string true "Verification Code" // @Router /v1/register/{number}/verify/{token} [post] func (a *Api) VerifyRegisteredNumber(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } token := c.Param("token") @@ -484,6 +492,12 @@ func (a *Api) Send(c *gin.Context) { base64Attachments = append(base64Attachments, req.Base64Attachment) } + _, req.Number, err = utils.ParseAccountIdentifier(req.Number) + if err != nil { + c.JSON(400, Error{Msg: err.Error()}) + return + } + resp, err := a.signalClient.SendV1(req.Number, req.Message, req.Recipients, base64Attachments, req.IsGroup) if err != nil { c.JSON(400, Error{Msg: err.Error()}) @@ -522,8 +536,9 @@ func (a *Api) SendV2(c *gin.Context) { return } - if req.Number == "" { - c.JSON(400, gin.H{"error": "Couldn't process request - please provide a valid number"}) + _, req.Number, err = utils.ParseAccountIdentifier(req.Number) + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) return } @@ -685,7 +700,7 @@ func StringToBool(input string) bool { // @Produce json // @Success 200 {object} []string // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param timeout query string false "Receive timeout in seconds (default: 1)" // @Param ignore_attachments query string false "Specify whether the attachments of the received message should be ignored" (default: false)" // @Param ignore_stories query string false "Specify whether stories should be ignored when receiving messages" (default: false)" @@ -695,9 +710,9 @@ func StringToBool(input string) bool { // @Param send_read_receipts query string false "Specify whether read receipts should be sent when receiving messages" (default: false)" // @Router /v1/receive/{number} [get] func (a *Api) Receive(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -775,12 +790,12 @@ func (a *Api) Receive(c *gin.Context) { // @Success 201 {object} CreateGroupResponse // @Failure 400 {object} Error // @Param data body CreateGroupRequest true "Input Data" -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Router /v1/groups/{number} [post] func (a *Api) CreateGroup(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -847,13 +862,13 @@ func (a *Api) CreateGroup(c *gin.Context) { // @Success 204 {string} OK // @Failure 400 {object} Error // @Param data body ChangeGroupMembersRequest true "Members" -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param groupid path string true "Group ID" // @Router /v1/groups/{number}/{groupid}/members [post] func (a *Api) AddMembersToGroup(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -901,13 +916,13 @@ func (a *Api) AddMembersToGroup(c *gin.Context) { // @Success 204 {string} OK // @Failure 400 {object} Error // @Param data body ChangeGroupMembersRequest true "Members" -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param groupid path string true "Group ID" // @Router /v1/groups/{number}/{groupid}/members [delete] func (a *Api) RemoveMembersFromGroup(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -950,13 +965,13 @@ func (a *Api) RemoveMembersFromGroup(c *gin.Context) { // @Success 204 {string} OK // @Failure 400 {object} Error // @Param data body ChangeGroupAdminsRequest true "Admins" -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param groupid path string true "Group ID" // @Router /v1/groups/{number}/{groupid}/admins [post] func (a *Api) AddAdminsToGroup(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -999,13 +1014,13 @@ func (a *Api) AddAdminsToGroup(c *gin.Context) { // @Success 204 {string} OK // @Failure 400 {object} Error // @Param data body ChangeGroupAdminsRequest true "Admins" -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param groupid path string true "Group ID" // @Router /v1/groups/{number}/{groupid}/admins [delete] func (a *Api) RemoveAdminsFromGroup(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -1047,13 +1062,13 @@ func (a *Api) RemoveAdminsFromGroup(c *gin.Context) { // @Produce json // @Success 200 {object} []client.GroupEntry // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param expand query bool false "Expand the response to show more details (default: false)" // @Router /v1/groups/{number} [get] func (a *Api) GetGroups(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -1087,14 +1102,14 @@ func (a *Api) GetGroups(c *gin.Context) { // @Produce json // @Success 200 {object} client.GroupEntry // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param groupid path string true "Group ID" // @Param expand query bool false "Expand the response to show more details (default: false)" // @Router /v1/groups/{number}/{groupid} [get] func (a *Api) GetGroup(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } groupId := c.Param("groupid") @@ -1134,13 +1149,13 @@ func (a *Api) GetGroup(c *gin.Context) { // @Produce json // @Success 200 {string} string "Image" // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param groupid path string true "Group ID" // @Router /v1/groups/{number}/{groupid}/avatar [get] func (a *Api) GetGroupAvatar(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } groupId := c.Param("groupid") @@ -1168,14 +1183,14 @@ func (a *Api) GetGroupAvatar(c *gin.Context) { // @Produce json // @Success 200 {string} string "OK" // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param groupid path string true "Group Id" // @Router /v1/groups/{number}/{groupid} [delete] func (a *Api) DeleteGroup(c *gin.Context) { base64EncodedGroupId := c.Param("groupid") - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -1205,14 +1220,14 @@ func (a *Api) DeleteGroup(c *gin.Context) { // @Success 201 {string} string "OK" // @Failure 400 {object} Error // @Param data body PinMessageInGroupRequest true "Pin" -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param groupid path string true "Group Id" // @Router /v1/groups/{number}/{groupid}/pin-message [post] func (a *Api) PinMessageInGroup(c *gin.Context) { base64EncodedGroupId := c.Param("groupid") - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -1256,14 +1271,14 @@ func (a *Api) PinMessageInGroup(c *gin.Context) { // @Success 201 {string} string "OK" // @Failure 400 {object} Error // @Param data body UnpinMessageInGroupRequest true "Unpin" -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param groupid path string true "Group Id" // @Router /v1/groups/{number}/{groupid}/pin-message [delete] func (a *Api) UnpinMessageInGroup(c *gin.Context) { base64EncodedGroupId := c.Param("groupid") - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -1306,11 +1321,11 @@ func (a *Api) UnpinMessageInGroup(c *gin.Context) { // @Summary Link device and generate QR code. // @Tags Devices -// @Description Link device and generate QR code +// @Description Link this service as a secondary device. Numberless accounts use the same QR flow: signal-cli advertises capabilities=nopni in the link URI. The link command does not take an account selector. After linking, call list accounts and use the ACI when number is empty. // @Produce json // @Success 200 {string} string "Image" // @Param device_name query string true "Device Name" -// @Param qrcode_version query int false "QRCode Version (defaults to 10)" +// @Param qrcode_version query int false "QR code version. Omit to use the smallest version that fits the link URI." // @Failure 400 {object} Error // @Router /v1/qrcodelink [get] func (a *Api) GetQrCodeLink(c *gin.Context) { @@ -1322,7 +1337,7 @@ func (a *Api) GetQrCodeLink(c *gin.Context) { return } - qrCodeVersionInt := 10 + qrCodeVersionInt := 0 if qrCodeVersion != "" { var err error qrCodeVersionInt, err = strconv.Atoi(qrCodeVersion) @@ -1343,7 +1358,7 @@ func (a *Api) GetQrCodeLink(c *gin.Context) { // @Summary Get raw device link URI // @Tags Devices -// @Description Generate the deviceLinkUri string for linking without scanning a QR code. +// @Description Generate the deviceLinkUri string for linking without scanning a QR code. Numberless accounts use the same URI. signal-cli advertises capabilities=nopni. Do not pass an account selector to link. After linking, use the ACI from list accounts when number is empty. // @Produce json // @Param device_name query string true "Device Name" // @Success 200 {object} DeviceLinkUriResponse @@ -1367,19 +1382,19 @@ func (a *Api) GetQrCodeLinkUri(c *gin.Context) { // @Summary List all accounts // @Tags Accounts -// @Description Lists all of the accounts linked or registered +// @Description Lists all of the accounts linked or registered. number is an empty string when the account has no phone number; use aci as the account identifier on other endpoints in that case. // @Produce json -// @Success 200 {object} []string +// @Success 200 {object} []client.Account // @Failure 400 {object} Error // @Router /v1/accounts [get] func (a *Api) GetAccounts(c *gin.Context) { - devices, err := a.signalClient.GetAccounts() + accounts, err := a.signalClient.GetAccounts() if err != nil { c.JSON(500, Error{Msg: "Couldn't get list of accounts: " + err.Error()}) return } - c.JSON(200, devices) + c.JSON(200, accounts) } // @Summary List all attachments. @@ -1482,12 +1497,12 @@ func (a *Api) ServeAttachment(c *gin.Context) { // @Success 204 {string} OK // @Failure 400 {object} Error // @Param data body UpdateProfileRequest true "Profile Data" -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Router /v1/profiles/{number} [put] func (a *Api) UpdateProfile(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -1533,12 +1548,12 @@ func (a *Api) Health(c *gin.Context) { // @Description List all identities for the given number. // @Produce json // @Success 200 {object} []client.IdentityEntry -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Router /v1/identities/{number} [get] func (a *Api) ListIdentities(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -1562,13 +1577,13 @@ func (a *Api) ListIdentities(c *gin.Context) { // @Produce json // @Success 204 {string} OK // @Param data body TrustIdentityRequest true "Input Data" -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param numberToTrust path string true "Number To Trust" // @Router /v1/identities/{number}/trust/{numberToTrust} [put] func (a *Api) TrustIdentity(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -1672,13 +1687,13 @@ func (a *Api) GetConfiguration(c *gin.Context) { // @Produce json // @Success 204 {string} OK // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param groupid path string true "Group ID" // @Router /v1/groups/{number}/{groupid}/block [post] func (a *Api) BlockGroup(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -1709,13 +1724,13 @@ func (a *Api) BlockGroup(c *gin.Context) { // @Produce json // @Success 204 {string} OK // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param groupid path string true "Group ID" // @Router /v1/groups/{number}/{groupid}/join [post] func (a *Api) JoinGroup(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -1746,13 +1761,13 @@ func (a *Api) JoinGroup(c *gin.Context) { // @Produce json // @Success 204 {string} OK // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param groupid path string true "Group ID" // @Router /v1/groups/{number}/{groupid}/quit [post] func (a *Api) QuitGroup(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -1782,14 +1797,14 @@ func (a *Api) QuitGroup(c *gin.Context) { // @Produce json // @Success 204 {string} OK // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param groupid path string true "Group ID" // @Param data body UpdateGroupRequest true "Input Data" // @Router /v1/groups/{number}/{groupid} [put] func (a *Api) UpdateGroup(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -1871,7 +1886,7 @@ func (a *Api) UpdateGroup(c *gin.Context) { // @Success 204 {string} OK // @Failure 400 {object} Error // @Param data body SendReactionRequest true "Reaction" -// @Param number path string true "Registered phone number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Router /v1/reactions/{number} [post] func (a *Api) SendReaction(c *gin.Context) { var req SendReactionRequest @@ -1882,9 +1897,9 @@ func (a *Api) SendReaction(c *gin.Context) { return } - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -1924,7 +1939,7 @@ func (a *Api) SendReaction(c *gin.Context) { // @Success 204 {string} OK // @Failure 400 {object} Error // @Param data body RemoveReactionRequest true "Reaction" -// @Param number path string true "Registered phone number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Router /v1/reactions/{number} [delete] func (a *Api) RemoveReaction(c *gin.Context) { var req RemoveReactionRequest @@ -1935,9 +1950,9 @@ func (a *Api) RemoveReaction(c *gin.Context) { return } - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -1972,7 +1987,7 @@ func (a *Api) RemoveReaction(c *gin.Context) { // @Success 204 {string} OK // @Failure 400 {object} Error // @Param data body Receipt true "Receipt" -// @Param number path string true "Registered phone number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Router /v1/receipts/{number} [post] func (a *Api) SendReceipt(c *gin.Context) { var req Receipt @@ -1983,9 +1998,9 @@ func (a *Api) SendReceipt(c *gin.Context) { return } - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -2025,7 +2040,7 @@ func (a *Api) SendReceipt(c *gin.Context) { // @Produce json // @Success 204 {string} OK // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param data body TypingIndicatorRequest true "Type" // @Router /v1/typing-indicator/{number} [put] func (a *Api) SendStartTyping(c *gin.Context) { @@ -2037,9 +2052,9 @@ func (a *Api) SendStartTyping(c *gin.Context) { return } - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -2062,7 +2077,7 @@ func (a *Api) SendStartTyping(c *gin.Context) { // @Produce json // @Success 204 {string} OK // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param data body TypingIndicatorRequest true "Type" // @Router /v1/typing-indicator/{number} [delete] func (a *Api) SendStopTyping(c *gin.Context) { @@ -2074,9 +2089,9 @@ func (a *Api) SendStopTyping(c *gin.Context) { return } - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -2097,7 +2112,7 @@ func (a *Api) SendStopTyping(c *gin.Context) { // @Description Check if one or more phone numbers are registered with the Signal Service. // @Accept json // @Produce json -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param numbers query []string true "Numbers to check" collectionFormat(multi) // @Success 200 {object} []SearchResponse // @Failure 400 {object} Error @@ -2109,11 +2124,19 @@ func (a *Api) SearchForNumbers(c *gin.Context) { return } - number, err := url.PathUnescape(c.Param("number")) + rawNumber, err := url.PathUnescape(c.Param("number")) if err != nil { c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) return } + number := "" + if rawNumber != "" { + _, number, err = utils.ParseAccountIdentifier(rawNumber) + if err != nil { + c.JSON(400, Error{Msg: err.Error()}) + return + } + } searchResults, err := a.signalClient.SearchForNumbers(number, query["numbers"]) if err != nil { @@ -2135,15 +2158,15 @@ func (a *Api) SearchForNumbers(c *gin.Context) { // @Description Updates the info associated to a number on the contact list. // @Accept json // @Produce json -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Success 204 // @Param data body UpdateContactRequest true "Contact" // @Failure 400 {object} Error // @Router /v1/contacts/{number} [put] func (a *Api) UpdateContact(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -2176,15 +2199,15 @@ func (a *Api) UpdateContact(c *gin.Context) { // @Description Links another device to this device. Only works, if this is the master device. // @Accept json // @Produce json -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Success 204 // @Param data body AddDeviceRequest true "Request" // @Failure 400 {object} Error // @Router /v1/devices/{number} [post] func (a *Api) AddDevice(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -2212,14 +2235,14 @@ func (a *Api) AddDevice(c *gin.Context) { // @Description List linked devices associated to this device. // @Accept json // @Produce json -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Success 200 {object} []client.ListDevicesResponse // @Failure 400 {object} Error // @Router /v1/devices/{number} [get] func (a *Api) ListDevices(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -2239,15 +2262,15 @@ func (a *Api) ListDevices(c *gin.Context) { // @Summary Remove linked device // @Tags Devices // @Description Remove a linked device from the primary account. -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param deviceId path int true "Device ID from listDevices" // @Success 204 // @Failure 400 {object} Error // @Router /v1/devices/{number}/{deviceId} [delete] func (a *Api) RemoveDevice(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -2275,15 +2298,15 @@ func (a *Api) RemoveDevice(c *gin.Context) { // @Description Set account specific settings. // @Accept json // @Produce json -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Success 204 // @Param data body TrustModeRequest true "Request" // @Failure 400 {object} Error // @Router /v1/configuration/{number}/settings [post] func (a *Api) SetTrustMode(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -2318,15 +2341,15 @@ func (a *Api) SetTrustMode(c *gin.Context) { // @Description List account specific settings. // @Accept json // @Produce json -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Success 200 // @Param data body TrustModeResponse true "Request" // @Failure 400 {object} Error // @Router /v1/configuration/{number}/settings [get] func (a *Api) GetTrustMode(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -2350,14 +2373,14 @@ func (a *Api) GetTrustMode(c *gin.Context) { // @Description Send a synchronization message with the local contacts list to all linked devices. This command should only be used if this is the primary device. // @Accept json // @Produce json -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Success 204 // @Failure 400 {object} Error // @Router /v1/contacts/{number}/sync [post] func (a *Api) SendContacts(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -2378,15 +2401,15 @@ func (a *Api) SendContacts(c *gin.Context) { // @Description When running into rate limits, sometimes the limit can be lifted, by solving a CAPTCHA. To get the captcha token, go to https://signalcaptchas.org/challenge/generate.html For the staging environment, use: https://signalcaptchas.org/staging/registration/generate.html. The "challenge_token" is the token from the failed send attempt. The "captcha" is the captcha result, starting with signalcaptcha:// // @Accept json // @Produce json -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param data body RateLimitChallengeRequest true "Request" // @Success 204 // @Failure 400 {object} Error // @Router /v1/accounts/{number}/rate-limit-challenge [post] func (a *Api) SubmitRateLimitChallenge(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -2424,15 +2447,15 @@ func (a *Api) SubmitRateLimitChallenge(c *gin.Context) { // @Description Update the account attributes on the signal server. // @Accept json // @Produce json -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param data body UpdateAccountSettingsRequest true "Request" // @Success 201 // @Failure 400 {object} Error // @Router /v1/accounts/{number}/settings [put] func (a *Api) UpdateAccountSettings(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -2461,16 +2484,16 @@ func (a *Api) UpdateAccountSettings(c *gin.Context) { // @Description Allows to set the username that should be used for this account. This can either be just the nickname (e.g. test) or the complete username with discriminator (e.g. test.123). Returns the new username with discriminator and the username link. // @Accept json // @Produce json -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param data body SetUsernameRequest true "Request" // @Success 201 {object} client.SetUsernameResponse // @Success 204 // @Failure 400 {object} Error // @Router /v1/accounts/{number}/username [post] func (a *Api) SetUsername(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -2498,14 +2521,14 @@ func (a *Api) SetUsername(c *gin.Context) { // @Description Delete the username associated with this account. // @Accept json // @Produce json -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Success 204 // @Failure 400 {object} Error // @Router /v1/accounts/{number}/username [delete] func (a *Api) RemoveUsername(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -2526,15 +2549,15 @@ func (a *Api) RemoveUsername(c *gin.Context) { // @Description List Installed Sticker Packs. // @Accept json // @Produce json -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Success 204 // @Failure 400 {object} Error // @Success 200 {object} []client.ListInstalledStickerPacksResponse // @Router /v1/sticker-packs/{number} [get] func (a *Api) ListInstalledStickerPacks(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -2556,15 +2579,15 @@ func (a *Api) ListInstalledStickerPacks(c *gin.Context) { // @Description In order to add a sticker pack, browse to https://signalstickers.org/ and select the sticker pack you want to add. Then, press the "Add to Signal" button. If you look at the address bar in your browser you should see an URL in this format: https://signal.art/addstickers/#pack_id=XXX&pack_key=YYY, where XXX is the pack_id and YYY is the pack_key. // @Accept json // @Produce json -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Success 201 // @Failure 400 {object} Error // @Param data body AddStickerPackRequest true "Request" // @Router /v1/sticker-packs/{number} [post] func (a *Api) AddStickerPack(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -2593,13 +2616,13 @@ func (a *Api) AddStickerPack(c *gin.Context) { // @Description List all contacts for the given number. // @Produce json // @Success 200 {object} []client.ListContactsResponse -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param all_recipients query string false "Include all known recipients, not only contacts." (default: false)" // @Router /v1/contacts/{number} [get] func (a *Api) ListContacts(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -2627,13 +2650,13 @@ func (a *Api) ListContacts(c *gin.Context) { // @Description List a specific contact. // @Produce json // @Success 200 {object} client.ListContactsResponse -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param all_recipients query string false "Include all known recipients, not only contacts." (default: false)" // @Router /v1/contacts/{number}/{uuid} [get] func (a *Api) ListContact(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -2674,12 +2697,12 @@ func (a *Api) ListContact(c *gin.Context) { // @Description Returns the avatar of a contact. // @Produce json // @Success 200 {string} string "Image" -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Router /v1/contacts/{number}/{uuid}/avatar [get] func (a *Api) GetProfileAvatar(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -2710,13 +2733,13 @@ func (a *Api) GetProfileAvatar(c *gin.Context) { // @Produce json // @Success 201 // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param data body SetPinRequest true "Request" // @Router /v1/accounts/{number}/pin [post] func (a *Api) SetPin(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -2747,12 +2770,12 @@ func (a *Api) SetPin(c *gin.Context) { // @Produce json // @Success 204 // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Router /v1/accounts/{number}/pin [delete] func (a *Api) RemovePin(c *gin.Context) { - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } @@ -2777,7 +2800,7 @@ func (a *Api) RemovePin(c *gin.Context) { // @Produce json // @Success 201 {object} RemoteDeleteResponse // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param data body RemoteDeleteRequest true "Type" // @Router /v1/remote-delete/{number} [delete] func (a *Api) RemoteDelete(c *gin.Context) { @@ -2788,9 +2811,9 @@ func (a *Api) RemoteDelete(c *gin.Context) { return } - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -2814,7 +2837,7 @@ func (a *Api) RemoteDelete(c *gin.Context) { // @Produce json // @Success 201 {object} CreatePollResponse // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param data body CreatePollRequest true "Type" // @Router /v1/polls/{number} [post] func (a *Api) CreatePoll(c *gin.Context) { @@ -2825,9 +2848,9 @@ func (a *Api) CreatePoll(c *gin.Context) { return } - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -2870,7 +2893,7 @@ func (a *Api) CreatePoll(c *gin.Context) { // @Produce json // @Success 204 // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param data body VoteRequest true "Type" // @Router /v1/polls/{number}/vote [post] func (a *Api) VoteInPoll(c *gin.Context) { @@ -2881,9 +2904,9 @@ func (a *Api) VoteInPoll(c *gin.Context) { return } - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { @@ -2940,7 +2963,7 @@ func (a *Api) VoteInPoll(c *gin.Context) { // @Produce json // @Success 204 // @Failure 400 {object} Error -// @Param number path string true "Registered Phone Number" +// @Param number path string true "Account identifier (E.164 phone number or ACI UUID)" // @Param data body ClosePollRequest true "Type" // @Router /v1/polls/{number} [delete] func (a *Api) ClosePoll(c *gin.Context) { @@ -2951,9 +2974,9 @@ func (a *Api) ClosePoll(c *gin.Context) { return } - number, err := url.PathUnescape(c.Param("number")) + _, number, err := accountIdentifierFromParam(c) if err != nil { - c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + c.JSON(400, Error{Msg: err.Error()}) return } if number == "" { diff --git a/src/client/accounts_test.go b/src/client/accounts_test.go new file mode 100644 index 0000000..2c8eae9 --- /dev/null +++ b/src/client/accounts_test.go @@ -0,0 +1,109 @@ +package client + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +func TestParseListedAccountsNumberless(t *testing.T) { + raw := `[ + {"number": null, "aci": "11111111-1111-4111-8111-111111111111"}, + {"number": "", "aci": "22222222-2222-4222-8222-222222222222"} + ]` + + accounts, err := ParseListedAccounts(raw) + if err != nil { + t.Fatal(err) + } + if len(accounts) != 2 { + t.Fatalf("len = %d, want 2", len(accounts)) + } + + if accounts[0].Number != "" || accounts[0].Identifier() != "11111111-1111-4111-8111-111111111111" { + t.Fatalf("null number: %+v identifier %q", accounts[0], accounts[0].Identifier()) + } + if accounts[1].Number != "" || accounts[1].Identifier() != "22222222-2222-4222-8222-222222222222" { + t.Fatalf("empty number: %+v", accounts[1]) + } + + encoded, err := json.Marshal(accounts[0]) + if err != nil { + t.Fatal(err) + } + var decoded map[string]interface{} + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + if decoded["number"] != "" { + t.Fatalf("encoded number = %#v, want empty string", decoded["number"]) + } + if decoded["aci"] != "11111111-1111-4111-8111-111111111111" { + t.Fatalf("encoded aci = %#v", decoded["aci"]) + } +} + +func TestParseListedAccountsNumbered(t *testing.T) { + raw := `[{"number":"+12025550123","aci":"aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"}]` + accounts, err := ParseListedAccounts(raw) + if err != nil { + t.Fatal(err) + } + if len(accounts) != 1 { + t.Fatalf("len = %d", len(accounts)) + } + if accounts[0].Number != "+12025550123" { + t.Fatalf("number = %q", accounts[0].Number) + } + if accounts[0].Aci == nil || *accounts[0].Aci != "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" { + t.Fatalf("aci = %#v", accounts[0].Aci) + } + if accounts[0].Identifier() != "+12025550123" { + t.Fatalf("identifier = %q", accounts[0].Identifier()) + } +} + +func TestParseListedAccountsLegacyNumberOnly(t *testing.T) { + raw := `[{"number":"+12025550123"}]` + accounts, err := ParseListedAccounts(raw) + if err != nil { + t.Fatal(err) + } + if len(accounts) != 1 || accounts[0].Number != "+12025550123" || accounts[0].Aci != nil { + t.Fatalf("legacy account = %+v", accounts) + } +} + +func TestEncodeLinkQRFitsNumberlessLinkURI(t *testing.T) { + // Version 10 at the highest recovery level holds 976 bits. A numberless + // link URI that includes capabilities=nopni is longer than that. + content := "sgnl://linkdevice?uuid=11111111-1111-4111-8111-111111111111&pub_key=" + strings.Repeat("ab", 80) + "&capabilities=nopni" + if _, err := encodeLinkQR(content, 10); err == nil { + t.Fatal("version 10 accepted a link URI that is larger than its capacity") + } + q, err := encodeLinkQR(content, 0) + if err != nil { + t.Fatal(err) + } + png, err := q.PNG(256) + if err != nil { + t.Fatal(err) + } + if len(png) == 0 { + t.Fatal("encoded QR png is empty") + } +} + +func TestDeviceLinkCommandDoesNotSelectAnAccount(t *testing.T) { + cmd := deviceLinkCommand("/home/.local/share/signal-cli/", "signal-api") + want := []string{"--config", "/home/.local/share/signal-cli/", "link", "-n", "signal-api"} + if !reflect.DeepEqual(cmd, want) { + t.Fatalf("command = %#v, want %#v", cmd, want) + } + for _, arg := range cmd { + if arg == "-a" || arg == "--account" { + t.Fatalf("link command must not select an account: %#v", cmd) + } + } +} diff --git a/src/client/cli.go b/src/client/cli.go index 23d11e7..0ec9bfd 100644 --- a/src/client/cli.go +++ b/src/client/cli.go @@ -49,6 +49,13 @@ func stripInfoAndWarnMessages(input string) (string, string, string) { return output, infoMessages, warnMessages } +func isAccountSelector(candidate string) bool { + if _, _, err := utils.ParseAccountIdentifier(candidate); err == nil { + return true + } + return utils.IsPhoneNumber(candidate) +} + func (s *CliClient) Execute(wait bool, args []string, stdin string) (string, error) { containerId, err := getContainerId() @@ -71,7 +78,7 @@ func (s *CliClient) Execute(wait bool, args []string, stdin string) (string, err //check if args contain number trustModeStr := "" for i, arg := range args { - if (arg == "-a" || arg == "--account") && (((i + 1) < len(args)) && (utils.IsPhoneNumber(args[i+1]))) { + if (arg == "-a" || arg == "--account") && (i+1) < len(args) && isAccountSelector(args[i+1]) { number := args[i+1] trustMode, err := s.signalCliApiConfig.GetTrustModeForNumber(number) if err == nil { diff --git a/src/client/client.go b/src/client/client.go index c892fd4..6dc0d1f 100644 --- a/src/client/client.go +++ b/src/client/client.go @@ -1684,7 +1684,7 @@ func (s *SignalClient) GetQrCodeLink(deviceName string, qrCodeVersion int) ([]by return []byte{}, errors.New("Couldn't create QR code: " + err.Error()) } - q, err := qrcode.NewWithForcedVersion(string(resp.DeviceLinkUri), qrCodeVersion, qrcode.Highest) + q, err := encodeLinkQR(resp.DeviceLinkUri, qrCodeVersion) if err != nil { return []byte{}, errors.New("Couldn't create QR code: " + err.Error()) } @@ -1699,14 +1699,14 @@ func (s *SignalClient) GetQrCodeLink(deviceName string, qrCodeVersion int) ([]by return png, nil } - command := []string{"--config", s.signalCliConfig, "link", "-n", deviceName} + command := deviceLinkCommand(s.signalCliConfig, deviceName) tsdeviceLink, err := s.cliClient.Execute(false, command, "") if err != nil { return []byte{}, errors.New("Couldn't create QR code: " + err.Error()) } - q, err := qrcode.NewWithForcedVersion(string(tsdeviceLink), qrCodeVersion, qrcode.Highest) + q, err := encodeLinkQR(tsdeviceLink, qrCodeVersion) if err != nil { return []byte{}, errors.New("Couldn't create QR code: " + err.Error()) } @@ -1745,7 +1745,7 @@ func (s *SignalClient) GetDeviceLinkUri(deviceName string) (string, error) { return resp.DeviceLinkUri, nil } - cmd := []string{"--config", s.signalCliConfig, "link", "-n", deviceName} + cmd := deviceLinkCommand(s.signalCliConfig, deviceName) deviceLinkUri, err := s.cliClient.Execute(false, cmd, "") if err != nil { return "", errors.New("Couldn't create link URI: " + err.Error()) @@ -1771,8 +1771,73 @@ func (s *SignalClient) finishLinkAsync(jsonRpc2Client *JsonRpc2Client, deviceNam }() } -func (s *SignalClient) GetAccounts() ([]string, error) { - accounts := make([]string, 0) +// Account is one linked or registered signal-cli account. +// Number is empty when the account has no phone number. In that case Identifier is the ACI. +type Account struct { + Number string `json:"number" example:"+12025550123"` + Aci *string `json:"aci" example:"11111111-1111-4111-8111-111111111111" extensions:"x-nullable=true"` +} + +// Identifier is the value other endpoints accept in the :number path parameter. +func (a Account) Identifier() string { + if a.Number != "" { + return a.Number + } + if a.Aci != nil { + return *a.Aci + } + return "" +} + +type signalCliListedAccount struct { + Number *string `json:"number"` + Aci *string `json:"aci"` +} + +// ParseListedAccounts maps signal-cli listAccounts JSON into REST accounts. +// A null or empty number with a present aci is a phone-numberless account. +func ParseListedAccounts(raw string) ([]Account, error) { + var parsed []signalCliListedAccount + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return nil, err + } + + accounts := make([]Account, 0, len(parsed)) + for _, item := range parsed { + account := Account{} + if item.Number != nil { + account.Number = *item.Number + } + if item.Aci != nil && *item.Aci != "" { + aci := *item.Aci + account.Aci = &aci + } + if account.Identifier() == "" { + continue + } + accounts = append(accounts, account) + } + return accounts, nil +} + +// encodeLinkQR builds a QR code for a device link URI. +// version <= 0 selects the smallest version that fits. A positive version is forced. +func encodeLinkQR(content string, version int) (*qrcode.QRCode, error) { + if version <= 0 { + return qrcode.New(content, qrcode.Highest) + } + return qrcode.NewWithForcedVersion(content, version, qrcode.Highest) +} + +// deviceLinkCommand builds `signal-cli link -n `. +// Upstream forbids -a/--account on link. Numberless accounts are advertised +// by capabilities=nopni inside the URI signal-cli prints. +func deviceLinkCommand(signalCliConfig string, deviceName string) []string { + return []string{"--config", signalCliConfig, "link", "-n", deviceName} +} + +func (s *SignalClient) GetAccounts() ([]Account, error) { + accounts := make([]Account, 0) var rawData string var err error var jsonRpc2Client *JsonRpc2Client @@ -1794,21 +1859,7 @@ func (s *SignalClient) GetAccounts() ([]string, error) { } } - type Account struct { - Number string `json:"number"` - } - accountObjs := []Account{} - - err = json.Unmarshal([]byte(rawData), &accountObjs) - if err != nil { - return accounts, err - } - - for _, account := range accountObjs { - accounts = append(accounts, account.Number) - } - - return accounts, nil + return ParseListedAccounts(rawData) } func (s *SignalClient) GetAttachments() ([]string, error) { diff --git a/src/docs/docs.go b/src/docs/docs.go index be10280..db26180 100644 --- a/src/docs/docs.go +++ b/src/docs/docs.go @@ -385,6 +385,7 @@ const docTemplate = `{ "type": "string" }, "number": { + "description": "Account identifier (E.164 phone number or ACI UUID)", "type": "string" }, "recipients": { @@ -433,6 +434,7 @@ const docTemplate = `{ "type": "boolean" }, "number": { + "description": "Account identifier (E.164 phone number or ACI UUID)", "type": "string" }, "quote_author": { @@ -745,6 +747,24 @@ const docTemplate = `{ ], "type": "object" }, + "client.Account": { + "properties": { + "aci": { + "example": "11111111-1111-4111-8111-111111111111", + "type": "string", + "x-nullable": "true" + }, + "number": { + "example": "+12025550123", + "type": "string" + } + }, + "required": [ + "aci", + "number" + ], + "type": "object" + }, "client.ContactProfile": { "properties": { "about": { @@ -1152,9 +1172,13 @@ const docTemplate = `{ }, "targetSentTimestamp": { "type": "integer" + }, + "targetTimestamp": { + "type": "integer" } }, "required": [ + "targetTimestamp", "targetSentTimestamp" ], "type": "object" @@ -1967,11 +1991,15 @@ const docTemplate = `{ }, "receive.RemoteDelete": { "properties": { + "targetTimestamp": { + "type": "integer" + }, "timestamp": { "type": "integer" } }, "required": [ + "targetTimestamp", "timestamp" ], "type": "object" @@ -2458,7 +2486,7 @@ const docTemplate = `{ }, "/v1/accounts": { "get": { - "description": "Lists all of the accounts linked or registered", + "description": "Lists all of the accounts linked or registered. number is an empty string when the account has no phone number; use aci as the account identifier on other endpoints in that case.", "produces": [ "application/json" ], @@ -2467,7 +2495,7 @@ const docTemplate = `{ "description": "OK", "schema": { "items": { - "type": "string" + "$ref": "#/definitions/client.Account" }, "type": "array" } @@ -2490,7 +2518,7 @@ const docTemplate = `{ "description": "Removes a Signal Pin", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -2520,7 +2548,7 @@ const docTemplate = `{ "description": "Sets a new Signal Pin", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -2564,7 +2592,7 @@ const docTemplate = `{ "description": "When running into rate limits, sometimes the limit can be lifted, by solving a CAPTCHA. To get the captcha token, go to https://signalcaptchas.org/challenge/generate.html For the staging environment, use: https://signalcaptchas.org/staging/registration/generate.html. The \"challenge_token\" is the token from the failed send attempt. The \"captcha\" is the captcha result, starting with signalcaptcha://", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -2608,7 +2636,7 @@ const docTemplate = `{ "description": "Update the account attributes on the signal server.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -2652,7 +2680,7 @@ const docTemplate = `{ "description": "Delete the username associated with this account.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -2685,7 +2713,7 @@ const docTemplate = `{ "description": "Allows to set the username that should be used for this account. This can either be just the nickname (e.g. test) or the complete username with discriminator (e.g. test.123). Returns the new username with discriminator and the username link.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -2899,7 +2927,7 @@ const docTemplate = `{ "description": "List account specific settings.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -2941,7 +2969,7 @@ const docTemplate = `{ "description": "Set account specific settings.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -2982,7 +3010,7 @@ const docTemplate = `{ "description": "List all contacts for the given number.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3021,7 +3049,7 @@ const docTemplate = `{ "description": "Updates the info associated to a number on the contact list.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3065,7 +3093,7 @@ const docTemplate = `{ "description": "Send a synchronization message with the local contacts list to all linked devices. This command should only be used if this is the primary device.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3097,7 +3125,7 @@ const docTemplate = `{ "description": "List a specific contact.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3132,7 +3160,7 @@ const docTemplate = `{ "description": "Returns the avatar of a contact.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3164,7 +3192,7 @@ const docTemplate = `{ "description": "List linked devices associated to this device.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3203,7 +3231,7 @@ const docTemplate = `{ "description": "Links another device to this device. Only works, if this is the master device.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3247,7 +3275,7 @@ const docTemplate = `{ "description": "Delete all local data for the specified account. Only use this after unregistering the account or after removing a linked device.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3287,7 +3315,7 @@ const docTemplate = `{ "description": "Remove a linked device from the primary account.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3326,7 +3354,7 @@ const docTemplate = `{ "description": "List all Signal Groups.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3380,7 +3408,7 @@ const docTemplate = `{ } }, { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3418,7 +3446,7 @@ const docTemplate = `{ "description": "Delete the specified Signal Group.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3461,7 +3489,7 @@ const docTemplate = `{ "description": "List a specific Signal Group.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3510,7 +3538,7 @@ const docTemplate = `{ "description": "Update the state of a Signal Group.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3573,7 +3601,7 @@ const docTemplate = `{ } }, { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3625,7 +3653,7 @@ const docTemplate = `{ } }, { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3670,7 +3698,7 @@ const docTemplate = `{ "description": "Returns the avatar of a Signal Group.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3715,7 +3743,7 @@ const docTemplate = `{ "description": "Block the specified Signal Group.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3760,7 +3788,7 @@ const docTemplate = `{ "description": "Join the specified Signal Group.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3814,7 +3842,7 @@ const docTemplate = `{ } }, { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3866,7 +3894,7 @@ const docTemplate = `{ } }, { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3920,7 +3948,7 @@ const docTemplate = `{ } }, { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3972,7 +4000,7 @@ const docTemplate = `{ } }, { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4017,7 +4045,7 @@ const docTemplate = `{ "description": "Quit the specified Signal Group.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4079,7 +4107,7 @@ const docTemplate = `{ "description": "List all identities for the given number.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4120,7 +4148,7 @@ const docTemplate = `{ } }, { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4159,7 +4187,7 @@ const docTemplate = `{ "description": "Close a poll", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4201,7 +4229,7 @@ const docTemplate = `{ "description": "Create a new poll", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4248,7 +4276,7 @@ const docTemplate = `{ "description": "Answer a poll", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4298,7 +4326,7 @@ const docTemplate = `{ } }, { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4330,7 +4358,7 @@ const docTemplate = `{ }, "/v1/qrcodelink": { "get": { - "description": "Link device and generate QR code", + "description": "Link this service as a secondary device. Numberless accounts use the same QR flow: signal-cli advertises capabilities=nopni in the link URI. The link command does not take an account selector. After linking, call list accounts and use the ACI when number is empty.", "parameters": [ { "description": "Device Name", @@ -4340,7 +4368,7 @@ const docTemplate = `{ "type": "string" }, { - "description": "QRCode Version (defaults to 10)", + "description": "QR code version. Omit to use the smallest version that fits the link URI.", "in": "query", "name": "qrcode_version", "type": "integer" @@ -4371,7 +4399,7 @@ const docTemplate = `{ }, "/v1/qrcodelink/raw": { "get": { - "description": "Generate the deviceLinkUri string for linking without scanning a QR code.", + "description": "Generate the deviceLinkUri string for linking without scanning a QR code. Numberless accounts use the same URI. signal-cli advertises capabilities=nopni. Do not pass an account selector to link. After linking, use the ACI from list accounts when number is empty.", "parameters": [ { "description": "Device Name", @@ -4421,7 +4449,7 @@ const docTemplate = `{ } }, { - "description": "Registered phone number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4466,7 +4494,7 @@ const docTemplate = `{ } }, { - "description": "Registered phone number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4513,7 +4541,7 @@ const docTemplate = `{ } }, { - "description": "Registered phone number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4551,7 +4579,7 @@ const docTemplate = `{ "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", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4631,7 +4659,7 @@ const docTemplate = `{ "description": "Register a phone number with the signal network.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4674,7 +4702,7 @@ const docTemplate = `{ "description": "Verify a registered phone number with the signal network.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4727,7 +4755,7 @@ const docTemplate = `{ "description": "Delete a signal message", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4774,7 +4802,7 @@ const docTemplate = `{ "description": "Check if one or more phone numbers are registered with the Signal Service.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4867,7 +4895,7 @@ const docTemplate = `{ "description": "List Installed Sticker Packs.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4909,7 +4937,7 @@ const docTemplate = `{ "description": "In order to add a sticker pack, browse to https://signalstickers.org/ and select the sticker pack you want to add. Then, press the \"Add to Signal\" button. If you look at the address bar in your browser you should see an URL in this format: https://signal.art/addstickers/#pack_id=XXX\u0026pack_key=YYY, where XXX is the pack_id and YYY is the pack_key.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4953,7 +4981,7 @@ const docTemplate = `{ "description": "Hide Typing Indicator.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4998,7 +5026,7 @@ const docTemplate = `{ "description": "Show Typing Indicator.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -5045,7 +5073,7 @@ const docTemplate = `{ "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", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, diff --git a/src/docs/swagger.json b/src/docs/swagger.json index 2b5494d..c8e910b 100644 --- a/src/docs/swagger.json +++ b/src/docs/swagger.json @@ -380,6 +380,7 @@ "type": "string" }, "number": { + "description": "Account identifier (E.164 phone number or ACI UUID)", "type": "string" }, "recipients": { @@ -428,6 +429,7 @@ "type": "boolean" }, "number": { + "description": "Account identifier (E.164 phone number or ACI UUID)", "type": "string" }, "quote_author": { @@ -740,6 +742,24 @@ ], "type": "object" }, + "client.Account": { + "properties": { + "aci": { + "example": "11111111-1111-4111-8111-111111111111", + "type": "string", + "x-nullable": "true" + }, + "number": { + "example": "+12025550123", + "type": "string" + } + }, + "required": [ + "aci", + "number" + ], + "type": "object" + }, "client.ContactProfile": { "properties": { "about": { @@ -1147,9 +1167,13 @@ }, "targetSentTimestamp": { "type": "integer" + }, + "targetTimestamp": { + "type": "integer" } }, "required": [ + "targetTimestamp", "targetSentTimestamp" ], "type": "object" @@ -1962,11 +1986,15 @@ }, "receive.RemoteDelete": { "properties": { + "targetTimestamp": { + "type": "integer" + }, "timestamp": { "type": "integer" } }, "required": [ + "targetTimestamp", "timestamp" ], "type": "object" @@ -2453,7 +2481,7 @@ }, "/v1/accounts": { "get": { - "description": "Lists all of the accounts linked or registered", + "description": "Lists all of the accounts linked or registered. number is an empty string when the account has no phone number; use aci as the account identifier on other endpoints in that case.", "produces": [ "application/json" ], @@ -2462,7 +2490,7 @@ "description": "OK", "schema": { "items": { - "type": "string" + "$ref": "#/definitions/client.Account" }, "type": "array" } @@ -2485,7 +2513,7 @@ "description": "Removes a Signal Pin", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -2515,7 +2543,7 @@ "description": "Sets a new Signal Pin", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -2559,7 +2587,7 @@ "description": "When running into rate limits, sometimes the limit can be lifted, by solving a CAPTCHA. To get the captcha token, go to https://signalcaptchas.org/challenge/generate.html For the staging environment, use: https://signalcaptchas.org/staging/registration/generate.html. The \"challenge_token\" is the token from the failed send attempt. The \"captcha\" is the captcha result, starting with signalcaptcha://", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -2603,7 +2631,7 @@ "description": "Update the account attributes on the signal server.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -2647,7 +2675,7 @@ "description": "Delete the username associated with this account.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -2680,7 +2708,7 @@ "description": "Allows to set the username that should be used for this account. This can either be just the nickname (e.g. test) or the complete username with discriminator (e.g. test.123). Returns the new username with discriminator and the username link.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -2894,7 +2922,7 @@ "description": "List account specific settings.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -2936,7 +2964,7 @@ "description": "Set account specific settings.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -2977,7 +3005,7 @@ "description": "List all contacts for the given number.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3016,7 +3044,7 @@ "description": "Updates the info associated to a number on the contact list.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3060,7 +3088,7 @@ "description": "Send a synchronization message with the local contacts list to all linked devices. This command should only be used if this is the primary device.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3092,7 +3120,7 @@ "description": "List a specific contact.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3127,7 +3155,7 @@ "description": "Returns the avatar of a contact.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3159,7 +3187,7 @@ "description": "List linked devices associated to this device.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3198,7 +3226,7 @@ "description": "Links another device to this device. Only works, if this is the master device.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3242,7 +3270,7 @@ "description": "Delete all local data for the specified account. Only use this after unregistering the account or after removing a linked device.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3282,7 +3310,7 @@ "description": "Remove a linked device from the primary account.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3321,7 +3349,7 @@ "description": "List all Signal Groups.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3375,7 +3403,7 @@ } }, { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3413,7 +3441,7 @@ "description": "Delete the specified Signal Group.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3456,7 +3484,7 @@ "description": "List a specific Signal Group.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3505,7 +3533,7 @@ "description": "Update the state of a Signal Group.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3568,7 +3596,7 @@ } }, { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3620,7 +3648,7 @@ } }, { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3665,7 +3693,7 @@ "description": "Returns the avatar of a Signal Group.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3710,7 +3738,7 @@ "description": "Block the specified Signal Group.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3755,7 +3783,7 @@ "description": "Join the specified Signal Group.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3809,7 +3837,7 @@ } }, { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3861,7 +3889,7 @@ } }, { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3915,7 +3943,7 @@ } }, { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -3967,7 +3995,7 @@ } }, { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4012,7 +4040,7 @@ "description": "Quit the specified Signal Group.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4074,7 +4102,7 @@ "description": "List all identities for the given number.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4115,7 +4143,7 @@ } }, { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4154,7 +4182,7 @@ "description": "Close a poll", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4196,7 +4224,7 @@ "description": "Create a new poll", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4243,7 +4271,7 @@ "description": "Answer a poll", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4293,7 +4321,7 @@ } }, { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4325,7 +4353,7 @@ }, "/v1/qrcodelink": { "get": { - "description": "Link device and generate QR code", + "description": "Link this service as a secondary device. Numberless accounts use the same QR flow: signal-cli advertises capabilities=nopni in the link URI. The link command does not take an account selector. After linking, call list accounts and use the ACI when number is empty.", "parameters": [ { "description": "Device Name", @@ -4335,7 +4363,7 @@ "type": "string" }, { - "description": "QRCode Version (defaults to 10)", + "description": "QR code version. Omit to use the smallest version that fits the link URI.", "in": "query", "name": "qrcode_version", "type": "integer" @@ -4366,7 +4394,7 @@ }, "/v1/qrcodelink/raw": { "get": { - "description": "Generate the deviceLinkUri string for linking without scanning a QR code.", + "description": "Generate the deviceLinkUri string for linking without scanning a QR code. Numberless accounts use the same URI. signal-cli advertises capabilities=nopni. Do not pass an account selector to link. After linking, use the ACI from list accounts when number is empty.", "parameters": [ { "description": "Device Name", @@ -4416,7 +4444,7 @@ } }, { - "description": "Registered phone number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4461,7 +4489,7 @@ } }, { - "description": "Registered phone number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4508,7 +4536,7 @@ } }, { - "description": "Registered phone number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4546,7 +4574,7 @@ "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", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4626,7 +4654,7 @@ "description": "Register a phone number with the signal network.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4669,7 +4697,7 @@ "description": "Verify a registered phone number with the signal network.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4722,7 +4750,7 @@ "description": "Delete a signal message", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4769,7 +4797,7 @@ "description": "Check if one or more phone numbers are registered with the Signal Service.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4862,7 +4890,7 @@ "description": "List Installed Sticker Packs.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4904,7 +4932,7 @@ "description": "In order to add a sticker pack, browse to https://signalstickers.org/ and select the sticker pack you want to add. Then, press the \"Add to Signal\" button. If you look at the address bar in your browser you should see an URL in this format: https://signal.art/addstickers/#pack_id=XXX\u0026pack_key=YYY, where XXX is the pack_id and YYY is the pack_key.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4948,7 +4976,7 @@ "description": "Hide Typing Indicator.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -4993,7 +5021,7 @@ "description": "Show Typing Indicator.", "parameters": [ { - "description": "Registered Phone Number", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, @@ -5040,7 +5068,7 @@ "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", + "description": "Account identifier (E.164 phone number or ACI UUID)", "in": "path", "name": "number", "required": true, diff --git a/src/main.go b/src/main.go index 548d39b..30ac26f 100644 --- a/src/main.go +++ b/src/main.go @@ -5,6 +5,7 @@ import ( "flag" "io/ioutil" "net/http" + "net/url" "os" "plugin" "strconv" @@ -413,6 +414,7 @@ func main() { type SignalCliAccountConfig struct { Number string `json:"number"` + Uuid string `json:"uuid"` } type SignalCliAccountConfigs struct { @@ -442,11 +444,20 @@ func main() { for _, account := range signalCliAccountConfigs.Accounts { client := &http.Client{} + identifier := account.Number + if identifier == "" { + identifier = account.Uuid + } + if identifier == "" { + log.Error("AUTO_RECEIVE_SCHEDULE: Skipping account with no phone number or ACI") + continue + } - log.Debug("AUTO_RECEIVE_SCHEDULE: Calling receive for number ", account.Number) - req, err := http.NewRequest("GET", "http://127.0.0.1:"+port+"/v1/receive/"+account.Number, nil) + log.Debug("AUTO_RECEIVE_SCHEDULE: Calling receive for account ", identifier) + req, err := http.NewRequest("GET", "http://127.0.0.1:"+port+"/v1/receive/"+url.PathEscape(identifier), nil) if err != nil { - log.Error("AUTO_RECEIVE_SCHEDULE: Couldn't call receive for number ", account.Number, ": ", err.Error()) + log.Error("AUTO_RECEIVE_SCHEDULE: Couldn't call receive for account ", identifier, ": ", err.Error()) + continue } q := req.URL.Query() @@ -460,7 +471,8 @@ func main() { resp, err := client.Do(req) if err != nil { - log.Error("AUTO_RECEIVE_SCHEDULE: Couldn't call receive for number ", account.Number, ": ", err.Error()) + log.Error("AUTO_RECEIVE_SCHEDULE: Couldn't call receive for account ", identifier, ": ", err.Error()) + continue } if resp.StatusCode != 200 { @@ -481,7 +493,7 @@ func main() { continue } - log.Error("AUTO_RECEIVE_SCHEDULE: Couldn't call receive for number ", account.Number, ": ", receiveResponse) + log.Error("AUTO_RECEIVE_SCHEDULE: Couldn't call receive for account ", identifier, ": ", receiveResponse) } } } else { diff --git a/src/utils/account_identifier.go b/src/utils/account_identifier.go new file mode 100644 index 0000000..4095668 --- /dev/null +++ b/src/utils/account_identifier.go @@ -0,0 +1,46 @@ +package utils + +import ( + "errors" + "regexp" + "strings" +) + +// IdentifierType is the kind of account selector signal-cli accepts. +// E.164 phone numbers and ACI UUIDs are disjoint, so one path parameter can carry either. +type IdentifierType int + +const ( + IdentifierE164 IdentifierType = iota + IdentifierACI +) + +var ( + e164AccountPattern = regexp.MustCompile(`^\+?[1-9]\d{1,14}$`) + aciAccountPattern = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) +) + +// ParseAccountIdentifier accepts an E.164 phone number or an ACI UUID. +// ACI values are returned in lowercase, which is the form signal-cli stores. +func ParseAccountIdentifier(s string) (IdentifierType, string, error) { + s = strings.TrimSpace(s) + if e164AccountPattern.MatchString(s) { + return IdentifierE164, s, nil + } + if aciAccountPattern.MatchString(s) { + return IdentifierACI, strings.ToLower(s), nil + } + return 0, "", errors.New("Couldn't process request - invalid account identifier (expected an E.164 phone number or an ACI UUID)") +} + +// SignalCliAccountSelector returns the global signal-cli account flag and value. +// Numbered and numberless accounts are both selected with -a/--account. +// -n is the device name on `link` and is not an account selector; `link` must not be given -a. +func SignalCliAccountSelector(typ IdentifierType, value string) (string, string) { + switch typ { + case IdentifierE164, IdentifierACI: + return "-a", value + default: + return "-a", value + } +} diff --git a/src/utils/account_identifier_test.go b/src/utils/account_identifier_test.go new file mode 100644 index 0000000..6ccfad6 --- /dev/null +++ b/src/utils/account_identifier_test.go @@ -0,0 +1,81 @@ +package utils + +import "testing" + +func TestParseAccountIdentifierE164(t *testing.T) { + cases := []string{"+12025550123", "+12", "12025550123"} + for _, raw := range cases { + typ, value, err := ParseAccountIdentifier(raw) + if err != nil { + t.Fatalf("%q: %v", raw, err) + } + if typ != IdentifierE164 { + t.Fatalf("%q: type = %v, want E164", raw, typ) + } + if value != raw { + t.Fatalf("%q: value = %q", raw, value) + } + flag, selected := SignalCliAccountSelector(typ, value) + if flag != "-a" || selected != raw { + t.Fatalf("%q: selector = %s %s", raw, flag, selected) + } + } +} + +func TestParseAccountIdentifierACI(t *testing.T) { + raw := "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE" + want := "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + typ, value, err := ParseAccountIdentifier(raw) + if err != nil { + t.Fatal(err) + } + if typ != IdentifierACI { + t.Fatalf("type = %v, want ACI", typ) + } + if value != want { + t.Fatalf("value = %q, want %q", value, want) + } + + typ, value, err = ParseAccountIdentifier(want) + if err != nil { + t.Fatal(err) + } + if typ != IdentifierACI || value != want { + t.Fatalf("lowercase UUID: type=%v value=%q", typ, value) + } + + flag, selected := SignalCliAccountSelector(typ, value) + if flag != "-a" || selected != want { + t.Fatalf("selector = %s %s, want -a %s", flag, selected, want) + } +} + +func TestParseAccountIdentifierInvalid(t *testing.T) { + cases := []string{ + "", + "+", + "+0123", + "+1", + "+1234567890123456", + "+12 345", + "not-an-account", + "11111111-1111-4111-8111-11111111111", + "11111111111141118111111111111111", + "GGGGGGGG-1111-4111-8111-111111111111", + } + for _, raw := range cases { + if _, _, err := ParseAccountIdentifier(raw); err == nil { + t.Fatalf("%q: expected error", raw) + } + } +} + +func TestParseAccountIdentifierTrimsSpace(t *testing.T) { + typ, value, err := ParseAccountIdentifier(" +12025550123 ") + if err != nil { + t.Fatal(err) + } + if typ != IdentifierE164 || value != "+12025550123" { + t.Fatalf("got %v %q", typ, value) + } +}