From 2e507699c53dd7b33545e99f8983f40e147bd409 Mon Sep 17 00:00:00 2001 From: Samuel Rodrigues Date: Thu, 8 Jan 2026 15:46:13 +0000 Subject: [PATCH] Changed the ListDevices endpoint to return the device id, created three new endpoints, one to return the device link to permit new device links without the qrcode, one to remove the linked devices and another to delete local-data --- src/api/api.go | 103 +++++++++++++++++++++++++++++++++++++ src/client/client.go | 118 ++++++++++++++++++++++++++++++++++++------- src/main.go | 3 ++ 3 files changed, 205 insertions(+), 19 deletions(-) diff --git a/src/api/api.go b/src/api/api.go index becf036..547cf04 100644 --- a/src/api/api.go +++ b/src/api/api.go @@ -220,6 +220,14 @@ type RemoteDeleteRequest struct { Timestamp int64 `json:"timestamp"` } +type DeleteLocalAccountDataRequest struct { + IgnoreRegistered bool `json:"ignore_registered" example:"false"` +} + +type DeviceLinkUriResponse struct { + DeviceLinkUri string `json:"device_link_uri"` +} + type Api struct { signalClient *client.SignalClient wsMutex sync.Mutex @@ -328,6 +336,43 @@ func (a *Api) UnregisterNumber(c *gin.Context) { c.Writer.WriteHeader(204) } +// @Summary Delete local account data +// @Tags Devices +// @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 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")) + if err != nil { + c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + return + } + if number == "" { + c.JSON(400, Error{Msg: "Couldn't process request - number missing"}) + return + } + + req := DeleteLocalAccountDataRequest{} + if c.Request.Body != nil && c.Request.ContentLength != 0 { + if err := c.BindJSON(&req); err != nil { + c.JSON(400, Error{Msg: "Couldn't process request - invalid request"}) + return + } + } + + if err := a.signalClient.DeleteLocalAccountData(number, req.IgnoreRegistered); err != nil { + c.JSON(400, Error{Msg: err.Error()}) + return + } + + c.Status(http.StatusNoContent) +} + // @Summary Verify a registered phone number. // @Tags Devices // @Description Verify a registered phone number with the signal network. @@ -1103,6 +1148,30 @@ func (a *Api) GetQrCodeLink(c *gin.Context) { c.Data(200, "image/png", png) } +// @Summary Get raw device link URI +// @Tags Devices +// @Description Generate the deviceLinkUri string for linking without scanning a QR code. +// @Produce json +// @Param device_name query string true "Device Name" +// @Success 200 {object} DeviceLinkUriResponse +// @Failure 400 {object} Error +// @Router /v1/qrcodelink/raw [get] +func (a *Api) GetQrCodeLinkUri(c *gin.Context) { + deviceName := c.Query("device_name") + if deviceName == "" { + c.JSON(400, Error{Msg: "Please provide a name for the device"}) + return + } + + deviceLinkUri, err := a.signalClient.GetDeviceLinkUri(deviceName) + if err != nil { + c.JSON(400, Error{Msg: err.Error()}) + return + } + + c.JSON(200, DeviceLinkUriResponse{DeviceLinkUri: deviceLinkUri}) +} + // @Summary List all accounts // @Tags Accounts // @Description Lists all of the accounts linked or registered @@ -1974,6 +2043,40 @@ func (a *Api) ListDevices(c *gin.Context) { c.JSON(200, devices) } +// @Summary Remove linked device +// @Tags Devices +// @Description Remove a linked device from the primary account. +// @Param number path string true "Registered Phone Number" +// @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")) + if err != nil { + c.JSON(400, Error{Msg: "Couldn't process request - malformed number"}) + return + } + if number == "" { + c.JSON(400, Error{Msg: "Couldn't process request - number missing"}) + return + } + + deviceIdStr := c.Param("deviceId") + deviceId, err := strconv.ParseInt(deviceIdStr, 10, 64) + if err != nil { + c.JSON(400, Error{Msg: "deviceId must be numeric"}) + return + } + + err = a.signalClient.RemoveDevice(number, deviceId) + if err != nil { + c.JSON(400, Error{Msg: err.Error()}) + return + } + c.Status(http.StatusNoContent) +} + // @Summary Set account specific settings. // @Tags General // @Description Set account specific settings. diff --git a/src/client/client.go b/src/client/client.go index 379bff4..d6558b2 100644 --- a/src/client/client.go +++ b/src/client/client.go @@ -232,6 +232,7 @@ type ListContactsResponse struct { } type ListDevicesResponse struct { + Id int64 `json:"id"` Name string `json:"name"` LastSeenTimestamp int64 `json:"last_seen_timestamp"` CreationTimestamp int64 `json:"creation_timestamp"` @@ -783,6 +784,32 @@ func (s *SignalClient) UnregisterNumber(number string, deleteAccount bool, delet return err } +func (s *SignalClient) DeleteLocalAccountData(number string, ignoreRegistered bool) error { + if s.signalCliMode == JsonRpc { + type Request struct { + IgnoreRegistered bool `json:"ignore-registered,omitempty"` + } + req := Request{} + if ignoreRegistered { + req.IgnoreRegistered = true + } + + jsonRpc2Client, err := s.getJsonRpc2Client() + if err != nil { + return err + } + _, err = jsonRpc2Client.getRaw("deleteLocalAccountData", &number, req) + return err + } else { + cmd := []string{"--config", s.signalCliConfig, "-a", number, "deleteLocalAccountData"} + if ignoreRegistered { + cmd = append(cmd, "--ignore-registered") + } + _, err := s.cliClient.Execute(true, cmd, "") + return err + } +} + func (s *SignalClient) VerifyRegisteredNumber(number string, token string, pin string) error { if s.signalCliMode == JsonRpc { type Request struct { @@ -1463,25 +1490,7 @@ func (s *SignalClient) GetQrCodeLink(deviceName string, qrCodeVersion int) ([]by return []byte{}, errors.New("Couldn't create QR code: " + err.Error()) } - go (func() { - type FinishRequest struct { - DeviceLinkUri string `json:"deviceLinkUri"` - DeviceName string `json:"deviceName"` - } - - req := FinishRequest{ - DeviceLinkUri: resp.DeviceLinkUri, - DeviceName: deviceName, - } - - result, err := jsonRpc2Client.getRaw("finishLink", nil, &req) - if err != nil { - log.Debug("Error linking device: ", err.Error()) - return - } - log.Debug("Linking device result: ", result) - s.signalCliApiConfig.Load(s.signalCliApiConfigPath) - })() + s.finishLinkAsync(jsonRpc2Client, deviceName, resp.DeviceLinkUri) return png, nil } @@ -1506,6 +1515,57 @@ func (s *SignalClient) GetQrCodeLink(deviceName string, qrCodeVersion int) ([]by return png, nil } +func (s *SignalClient) GetDeviceLinkUri(deviceName string) (string, error) { + if s.signalCliMode == JsonRpc { + type StartResponse struct { + DeviceLinkUri string `json:"deviceLinkUri"` + } + jsonRpc2Client, err := s.getJsonRpc2Client() + if err != nil { + return "", err + } + + raw, err := jsonRpc2Client.getRaw("startLink", nil, struct{}{}) + if err != nil { + return "", errors.New("Couldn't start link: " + err.Error()) + } + + var resp StartResponse + if err := json.Unmarshal([]byte(raw), &resp); err != nil { + return "", errors.New("Couldn't parse startLink response: " + err.Error()) + } + + // Complete the linking handshake in the background, just like GetQrCodeLink does. + s.finishLinkAsync(jsonRpc2Client, deviceName, resp.DeviceLinkUri) + return resp.DeviceLinkUri, nil + } + + cmd := []string{"--config", s.signalCliConfig, "link", "-n", deviceName} + deviceLinkUri, err := s.cliClient.Execute(false, cmd, "") + if err != nil { + return "", errors.New("Couldn't create link URI: " + err.Error()) + } + return strings.TrimSpace(deviceLinkUri), nil +} + +func (s *SignalClient) finishLinkAsync(jsonRpc2Client *JsonRpc2Client, deviceName string, deviceLinkUri string) { + type finishRequest struct { + DeviceLinkUri string `json:"deviceLinkUri"` + DeviceName string `json:"deviceName"` + } + + go func() { + req := finishRequest{DeviceLinkUri: deviceLinkUri, DeviceName: deviceName} + result, err := jsonRpc2Client.getRaw("finishLink", nil, &req) + if err != nil { + log.Debug("Error linking device: ", err.Error()) + return + } + log.Debug("Linking device result: ", result) + s.signalCliApiConfig.Load(s.signalCliApiConfigPath) + }() +} + func (s *SignalClient) GetAccounts() ([]string, error) { accounts := make([]string, 0) var rawData string @@ -2285,6 +2345,7 @@ func (s *SignalClient) ListDevices(number string) ([]ListDevicesResponse, error) for _, entry := range signalCliResp { deviceEntry := ListDevicesResponse{ + Id: entry.Id, Name: entry.Name, CreationTimestamp: entry.CreatedTimestamp, LastSeenTimestamp: entry.LastSeenTimestamp, @@ -2295,6 +2356,25 @@ func (s *SignalClient) ListDevices(number string) ([]ListDevicesResponse, error) return resp, nil } +func (s *SignalClient) RemoveDevice(number string, deviceId int64) error { + var err error + if s.signalCliMode == JsonRpc { + type Request struct { + DeviceId int64 `json:"deviceId"` + } + request := Request{DeviceId: deviceId} + jsonRpc2Client, err := s.getJsonRpc2Client() + if err != nil { + return err + } + _, err = jsonRpc2Client.getRaw("removeDevice", &number, request) + } else { + cmd := []string{"--config", s.signalCliConfig, "-a", number, "removeDevice", "--deviceId", strconv.FormatInt(deviceId, 10)} + _, err = s.cliClient.Execute(true, cmd, "") + } + return err +} + func (s *SignalClient) SetTrustMode(number string, trustMode utils.SignalCliTrustMode) error { if s.signalCliMode == JsonRpc { return errors.New("Not supported in json-rpc mode, use the environment variable JSON_RPC_TRUST_NEW_IDENTITIES instead!") diff --git a/src/main.go b/src/main.go index 0303960..4fb0d89 100644 --- a/src/main.go +++ b/src/main.go @@ -230,6 +230,7 @@ func main() { link := v1.Group("qrcodelink") { link.GET("", api.GetQrCodeLink) + link.GET("/raw", api.GetQrCodeLinkUri) } accounts := v1.Group("accounts") @@ -247,6 +248,8 @@ func main() { { devices.POST(":number", api.AddDevice) devices.GET(":number", api.ListDevices) + devices.DELETE(":number/:deviceId", api.RemoveDevice) + devices.DELETE(":number/local-data", api.DeleteLocalAccountData) } attachments := v1.Group("attachments")