diff --git a/internal/pipe/linkedin/client.go b/internal/pipe/linkedin/client.go index 1b81b3f3683..a4b8d0ae8d2 100644 --- a/internal/pipe/linkedin/client.go +++ b/internal/pipe/linkedin/client.go @@ -3,14 +3,18 @@ package linkedin import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net/http" + "github.com/caarlos0/log" "github.com/goreleaser/goreleaser/pkg/context" "golang.org/x/oauth2" ) +var ErrLinkedinForbidden = errors.New("forbidden. please check your permissions") + type oauthClientConfig struct { Context *context.Context AccessToken string @@ -55,20 +59,26 @@ func createLinkedInClient(cfg oauthClientConfig) (client, error) { }, nil } -// getProfileID returns the Current Member's ID +// getProfileIDLegacy returns the Current Member's ID +// it's legacy because it uses deprecated v2/me endpoint, that requires old permissions such as r_liteprofile // POST Share API requires a Profile ID in the 'owner' field // Format must be in: 'urn:li:person:PROFILE_ID' // https://docs.microsoft.com/en-us/linkedin/shared/integrations/people/profile-api#retrieve-current-members-profile -func (c client) getProfileID() (string, error) { +func (c client) getProfileIDLegacy() (string, error) { resp, err := c.client.Get(c.baseURL + "/v2/me") if err != nil { return "", fmt.Errorf("could not GET /v2/me: %w", err) } + if resp.StatusCode == http.StatusForbidden { + return "", ErrLinkedinForbidden + } + value, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("could not read response body: %w", err) } + defer resp.Body.Close() var result map[string]interface{} err = json.Unmarshal(value, &result) @@ -83,21 +93,74 @@ func (c client) getProfileID() (string, error) { return "", fmt.Errorf("could not find 'id' in result: %w", err) } +// getProfileSub returns the Current Member's sub (formally ID) - requires 'profile' permission +// POST Share API requires a Profile ID in the 'owner' field +// Format must be in: 'urn:li:person:PROFILE_SUB' +// https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2#api-request-to-retreive-member-details +func (c client) getProfileSub() (string, error) { + resp, err := c.client.Get(c.baseURL + "/v2/userinfo") + if err != nil { + return "", fmt.Errorf("could not GET /v2/userinfo: %w", err) + } + + if resp.StatusCode == http.StatusForbidden { + return "", ErrLinkedinForbidden + } + + value, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("could not read response body: %w", err) + } + defer resp.Body.Close() + + var result map[string]interface{} + err = json.Unmarshal(value, &result) + if err != nil { + return "", fmt.Errorf("could not unmarshal: %w", err) + } + + if v, ok := result["sub"]; ok { + return v.(string), nil + } + + return "", fmt.Errorf("could not find 'sub' in result: %w", err) +} + +// Person or Organization URN - urn:li:person:PROFILE_IDENTIFIER +// Owner of the share. Required on create. +// tries to get the profile sub (formally id) first, if it fails, it tries to get the profile id (legacy) +// https://docs.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/share-api?tabs=http#schema +func (c client) getProfileURN() (string, error) { + // To build the URN, we need to get the profile sub (formally id) + profileSub, err := c.getProfileSub() + if err != nil { + if !errors.Is(err, ErrLinkedinForbidden) { + return "", fmt.Errorf("could not get profile sub: %w", err) + } + + log.Debug("could not get linkedin profile sub due to permission, getting profile id (legacy)") + + profileSub, err = c.getProfileIDLegacy() + if err != nil { + return "", fmt.Errorf("could not get profile id: %w", err) + } + } + + return fmt.Sprintf("urn:li:person:%s", profileSub), nil +} + func (c client) Share(message string) (string, error) { - // To get Owner of the share, we need to get profile id - profileID, err := c.getProfileID() + // To get Owner of the share, we need to get the profile URN + profileURN, err := c.getProfileURN() if err != nil { - return "", fmt.Errorf("could not get profile id: %w", err) + return "", fmt.Errorf("could not get profile URN: %w", err) } req := postShareRequest{ Text: postShareText{ Text: message, }, - // Person or Organization URN - // Owner of the share. Required on create. - // https://docs.microsoft.com/en-us/linkedin/marketing/integrations/community-management/shares/share-api?tabs=http#schema - Owner: fmt.Sprintf("urn:li:person:%s", profileID), + Owner: profileURN, } reqBytes, err := json.Marshal(req) @@ -116,6 +179,7 @@ func (c client) Share(message string) (string, error) { if err != nil { return "", fmt.Errorf("could not read from body: %w", err) } + defer resp.Body.Close() var result map[string]interface{} err = json.Unmarshal(body, &result) diff --git a/internal/pipe/linkedin/client_test.go b/internal/pipe/linkedin/client_test.go index 96b96e1ac46..0c30dccdf5b 100644 --- a/internal/pipe/linkedin/client_test.go +++ b/internal/pipe/linkedin/client_test.go @@ -58,7 +58,7 @@ func TestClient_Share(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { _, _ = io.WriteString(rw, ` { - "id": "foo", + "sub": "foo", "activity": "123456789" } `) @@ -83,3 +83,38 @@ func TestClient_Share(t *testing.T) { wantLink := "https://www.linkedin.com/feed/update/123456789" require.Equal(t, wantLink, link) } + +func TestClientLegacyProfile_Share(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/v2/userinfo" { + rw.WriteHeader(http.StatusForbidden) + return + } + // this is the response from /v2/me (legacy as a fallback) + _, _ = io.WriteString(rw, ` + { + "id": "foo", + "activity": "123456789" + } + `) + })) + defer server.Close() + + c, err := createLinkedInClient(oauthClientConfig{ + Context: testctx.New(), + AccessToken: "foo", + }) + if err != nil { + t.Fatalf("could not create client: %v", err) + } + + c.baseURL = server.URL + + link, err := c.Share("test") + if err != nil { + t.Fatalf("could not share: %v", err) + } + + wantLink := "https://www.linkedin.com/feed/update/123456789" + require.Equal(t, wantLink, link) +}