github.com/goreleaser/goreleaser@v1.25.1/internal/pipe/linkedin/client.go (about)

     1  package linkedin
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  
    11  	"github.com/caarlos0/log"
    12  	"github.com/goreleaser/goreleaser/pkg/context"
    13  	"golang.org/x/oauth2"
    14  )
    15  
    16  var ErrLinkedinForbidden = errors.New("forbidden. please check your permissions")
    17  
    18  type oauthClientConfig struct {
    19  	Context     *context.Context
    20  	AccessToken string
    21  }
    22  
    23  type client struct {
    24  	client  *http.Client
    25  	baseURL string
    26  }
    27  
    28  type postShareText struct {
    29  	Text string `json:"text"`
    30  }
    31  
    32  type postShareRequest struct {
    33  	Text  postShareText `json:"text"`
    34  	Owner string        `json:"owner"`
    35  }
    36  
    37  func createLinkedInClient(cfg oauthClientConfig) (client, error) {
    38  	if cfg.Context == nil {
    39  		return client{}, fmt.Errorf("context is nil")
    40  	}
    41  
    42  	if cfg.AccessToken == "" {
    43  		return client{}, fmt.Errorf("empty access token")
    44  	}
    45  
    46  	config := oauth2.Config{}
    47  
    48  	c := config.Client(cfg.Context, &oauth2.Token{
    49  		AccessToken: cfg.AccessToken,
    50  	})
    51  
    52  	if c == nil {
    53  		return client{}, fmt.Errorf("client is nil")
    54  	}
    55  
    56  	return client{
    57  		client:  c,
    58  		baseURL: "https://api.linkedin.com",
    59  	}, nil
    60  }
    61  
    62  // getProfileIDLegacy returns the Current Member's ID
    63  // it's legacy because it uses deprecated v2/me endpoint, that requires old permissions such as r_liteprofile
    64  // POST Share API requires a Profile ID in the 'owner' field
    65  // Format must be in: 'urn:li:person:PROFILE_ID'
    66  // https://docs.microsoft.com/en-us/linkedin/shared/integrations/people/profile-api#retrieve-current-members-profile
    67  func (c client) getProfileIDLegacy() (string, error) {
    68  	resp, err := c.client.Get(c.baseURL + "/v2/me")
    69  	if err != nil {
    70  		return "", fmt.Errorf("could not GET /v2/me: %w", err)
    71  	}
    72  
    73  	if resp.StatusCode == http.StatusForbidden {
    74  		return "", ErrLinkedinForbidden
    75  	}
    76  
    77  	value, err := io.ReadAll(resp.Body)
    78  	if err != nil {
    79  		return "", fmt.Errorf("could not read response body: %w", err)
    80  	}
    81  	defer resp.Body.Close()
    82  
    83  	var result map[string]interface{}
    84  	err = json.Unmarshal(value, &result)
    85  	if err != nil {
    86  		return "", fmt.Errorf("could not unmarshal: %w", err)
    87  	}
    88  
    89  	if v, ok := result["id"]; ok {
    90  		return v.(string), nil
    91  	}
    92  
    93  	return "", fmt.Errorf("could not find 'id' in result: %w", err)
    94  }
    95  
    96  // getProfileSub returns the Current Member's sub (formally ID) - requires 'profile' permission
    97  // POST Share API requires a Profile ID in the 'owner' field
    98  // Format must be in: 'urn:li:person:PROFILE_SUB'
    99  // https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2#api-request-to-retreive-member-details
   100  func (c client) getProfileSub() (string, error) {
   101  	resp, err := c.client.Get(c.baseURL + "/v2/userinfo")
   102  	if err != nil {
   103  		return "", fmt.Errorf("could not GET /v2/userinfo: %w", err)
   104  	}
   105  
   106  	if resp.StatusCode == http.StatusForbidden {
   107  		return "", ErrLinkedinForbidden
   108  	}
   109  
   110  	value, err := io.ReadAll(resp.Body)
   111  	if err != nil {
   112  		return "", fmt.Errorf("could not read response body: %w", err)
   113  	}
   114  	defer resp.Body.Close()
   115  
   116  	var result map[string]interface{}
   117  	if err := json.Unmarshal(value, &result); err != nil {
   118  		return "", fmt.Errorf("could not unmarshal: %w", err)
   119  	}
   120  
   121  	if v, ok := result["sub"]; ok {
   122  		return v.(string), nil
   123  	}
   124  
   125  	return "", fmt.Errorf("could not find 'sub' in result: %v", result)
   126  }
   127  
   128  // Person or Organization URN - urn:li:person:PROFILE_IDENTIFIER
   129  // Owner of the share. Required on create.
   130  // tries to get the profile sub (formally id) first, if it fails, it tries to get the profile id (legacy)
   131  // https://docs.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/share-api?tabs=http#schema
   132  func (c client) getProfileURN() (string, error) {
   133  	// To build the URN, we need to get the profile sub (formally id)
   134  	profileSub, err := c.getProfileSub()
   135  	if err != nil {
   136  		if !errors.Is(err, ErrLinkedinForbidden) {
   137  			return "", fmt.Errorf("could not get profile sub: %w", err)
   138  		}
   139  
   140  		log.Debug("could not get linkedin profile sub due to permission, getting profile id (legacy)")
   141  
   142  		profileSub, err = c.getProfileIDLegacy()
   143  		if err != nil {
   144  			return "", fmt.Errorf("could not get profile id: %w", err)
   145  		}
   146  	}
   147  
   148  	return fmt.Sprintf("urn:li:person:%s", profileSub), nil
   149  }
   150  
   151  func (c client) Share(message string) (string, error) {
   152  	// To get Owner of the share, we need to get the profile URN
   153  	profileURN, err := c.getProfileURN()
   154  	if err != nil {
   155  		return "", fmt.Errorf("could not get profile URN: %w", err)
   156  	}
   157  
   158  	req := postShareRequest{
   159  		Text: postShareText{
   160  			Text: message,
   161  		},
   162  		Owner: profileURN,
   163  	}
   164  
   165  	reqBytes, err := json.Marshal(req)
   166  	if err != nil {
   167  		return "", fmt.Errorf("could not marshal request: %w", err)
   168  	}
   169  
   170  	// Filling only required 'owner' and 'text' field is OK
   171  	// https://docs.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/share-api?tabs=http#sample-request-3
   172  	resp, err := c.client.Post(c.baseURL+"/v2/shares", "application/json", bytes.NewReader(reqBytes))
   173  	if err != nil {
   174  		return "", fmt.Errorf("could not POST /v2/shares: %w", err)
   175  	}
   176  
   177  	body, err := io.ReadAll(resp.Body)
   178  	if err != nil {
   179  		return "", fmt.Errorf("could not read from body: %w", err)
   180  	}
   181  	defer resp.Body.Close()
   182  
   183  	var result map[string]interface{}
   184  	err = json.Unmarshal(body, &result)
   185  	if err != nil {
   186  		return "", fmt.Errorf("could not unmarshal: %w", err)
   187  	}
   188  
   189  	// Activity URN
   190  	// URN of the activity associated with this share. Activities act as a wrapper around
   191  	// shares and articles to represent content in the LinkedIn feed. Read only.
   192  	if v, ok := result["activity"]; ok {
   193  		return fmt.Sprintf("https://www.linkedin.com/feed/update/%s", v.(string)), nil
   194  	}
   195  
   196  	return "", fmt.Errorf("could not find 'activity' in result: %w", err)
   197  }