diff --git a/docs/request-compatibility.md b/docs/request-compatibility.md new file mode 100644 index 0000000..e1d1ba4 --- /dev/null +++ b/docs/request-compatibility.md @@ -0,0 +1,5 @@ +The LinkedIn request headers retain the legacy compatibility pair app 4.1.1209 / build 211700. `X-LI-Track` contains one `osVersion`, the SDK string `36`. These descriptive headers do not establish a native Android session or official-client equivalence. The transport is Go `net/http`. + +All LinkedIn wrappers use one authenticated request boundary, which accepts only HTTPS `www.linkedin.com` Voyager API destinations and rejects redirects without following them. The external employer checker remains separate and credential-free. Injected HTTP clients are copied and receive the same redirect policy; injected transports still pass through request validation and header construction. + +Mutations receive one application-level send admission, without retry loops, idempotency headers or a body replay function. Go's standard transport can internally retry certain failures on reused connections, including failures before any bytes were written; an admission is not an exact packet count. Custom transports control their own internal behavior. Read retries on rate limits and timeouts remain for compatibility in M1; shared pacing, durable refusal state and retry-policy changes belong to M2. diff --git a/internal/api/client.go b/internal/api/client.go index 7b4d575..7a9206c 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -1,216 +1,113 @@ -// Package api provides an HTTP client for LinkedIn's internal GraphQL API. -// -// LinkedIn's Android app communicates via HTTPS to voyager.linkedin.com using -// a combination of standard HTTP headers and RestLi-encoded query variables. -// This client replicates the required headers so the server treats requests as -// coming from the official Android client. package api import ( + "context" "encoding/json" - "errors" "fmt" "io" - "math" "net/http" "net/url" - "strings" "time" "github.com/yashiels/linkedin-cli/internal/auth" "github.com/yashiels/linkedin-cli/internal/restli" - "github.com/yashiels/linkedin-cli/internal/types" ) const ( - baseURL = "https://www.linkedin.com" - graphQLPath = "/voyager/api/graphql" - - defaultTimeout = 30 * time.Second - maxRetries = 4 - baseBackoff = 1 * time.Second - - // Static Android client fingerprint mirroring decompiled APK values. - trackHeader = `{"osName":"Android OS","osVersion":"36","clientVersion":"4.1.1209",` + - `"model":"samsung_SM-S901E","displayDensity":2.625,"displayWidth":1080,` + - `"displayHeight":2340,"osVersion":"BP2A.250605.031.A3","timezoneOffset":2,` + - `"interfaceLocale":"en_US"}` - - userAgent = "com.linkedin.android/211700 (Linux; U; Android 16; en_ZA; " + + baseURL = "https://www.linkedin.com" + graphQLPath = "/voyager/api/graphql" + defaultTimeout = 30 * time.Second + maxRetries = 4 + baseBackoff = time.Second + legacyAppVersion = "4.1.1209" + legacyAppBuild = "211700" + userAgent = "com.linkedin.android/" + legacyAppBuild + " (Linux; U; Android 16; en_ZA; " + "SM-S901E; Build/BP2A.250605.031.A3; Cronet/127.0.6533.65)" ) -// Client is a configured LinkedIn API client. +type trackingHeader struct { + OSName string `json:"osName"` + OSVersion string `json:"osVersion"` + ClientVersion string `json:"clientVersion"` + Model string `json:"model"` + DisplayDensity float64 `json:"displayDensity"` + DisplayWidth int `json:"displayWidth"` + DisplayHeight int `json:"displayHeight"` + TimezoneOffset int `json:"timezoneOffset"` + InterfaceLocale string `json:"interfaceLocale"` +} + type Client struct { http *http.Client creds auth.Credentials verbose bool debug bool errOut io.Writer + timeout *time.Duration } -// Option configures a Client. type Option func(*Client) -// WithTimeout sets the HTTP timeout (default 30 s). func WithTimeout(d time.Duration) Option { - return func(c *Client) { c.http.Timeout = d } + return func(c *Client) { c.timeout = &d } } -// WithVerbose enables request/response logging to errOut. -func WithVerbose(v bool) Option { return func(c *Client) { c.verbose = v } } - -// WithDebug enables full body logging to errOut. -func WithDebug(v bool) Option { return func(c *Client) { c.debug = v } } - -// WithErrWriter sets the writer used for diagnostic output (default os.Stderr). +func WithVerbose(v bool) Option { return func(c *Client) { c.verbose = v } } +func WithDebug(v bool) Option { return func(c *Client) { c.debug = v } } func WithErrWriter(w io.Writer) Option { return func(c *Client) { c.errOut = w } } -func WithHTTPClient(client *http.Client) Option { return func(c *Client) { c.http = client } } +func WithHTTPClient(client *http.Client) Option { + return func(c *Client) { + if client != nil { + owned := *client + c.http = &owned + } + } +} -// New creates a Client using the provided credentials. func New(creds auth.Credentials, opts ...Option) *Client { - c := &Client{ - http: &http.Client{ - Timeout: defaultTimeout, - // Detect session expiry by catching redirects to login pages or - // self-redirect loops. LinkedIn returns a 302 back to the same URL - // (with set-cookie: li_at=delete me) when li_at has expired. - CheckRedirect: func(req *http.Request, via []*http.Request) error { - if strings.Contains(req.URL.Path, "/login") || - strings.Contains(req.URL.Path, "/authwall") || - strings.Contains(req.URL.Path, "/uas/login") { - return types.AuthError("session expired — run 'lnk auth login' to refresh") - } - // Self-redirect loop: LinkedIn sends 302 → same URL when li_at expires. - if len(via) > 0 && req.URL.String() == via[len(via)-1].URL.String() { - return types.AuthError("session expired — run 'lnk auth login' to refresh") - } - if len(via) >= 3 { - return types.AuthError("too many redirects — session may be expired, run 'lnk auth login'") - } - return nil - }, - }, - creds: creds, - errOut: io.Discard, - } + c := &Client{http: &http.Client{Timeout: defaultTimeout}, creds: creds, errOut: io.Discard} for _, o := range opts { o(c) } + if c.timeout != nil { + c.http.Timeout = *c.timeout + } + c.http.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } return c } -// QueryGraphQL executes a LinkedIn GraphQL query by name and ID with the given -// variables. variables must be encodable by the restli package. -// -// It returns the raw JSON response body on success. On rate-limit it retries -// with exponential back-off up to maxRetries times. func (c *Client) QueryGraphQL(queryName, queryID string, variables interface{}) (json.RawMessage, error) { varStr, err := restli.Encode(variables) if err != nil { return nil, fmt.Errorf("api: cannot encode variables: %w", err) } - - // Build URL. - u, _ := url.Parse(baseURL + graphQLPath) - // Build raw query manually to avoid double-encoding the RestLi variables. - // The RestLi encoder already URL-encodes special characters within values. - u.RawQuery = "queryId=" + url.QueryEscape(queryID) + - "&queryName=" + url.QueryEscape(queryName) + - "&variables=" + varStr - - var lastErr error - for attempt := 0; attempt <= maxRetries; attempt++ { - if attempt > 0 { - wait := time.Duration(math.Pow(2, float64(attempt-1))) * baseBackoff - c.logf("rate limited, retrying in %s (attempt %d/%d)", wait, attempt, maxRetries) - time.Sleep(wait) - } - - req, err := http.NewRequest(http.MethodGet, u.String(), nil) - if err != nil { - return nil, fmt.Errorf("api: cannot build request: %w", err) - } - - c.injectHeaders(req) - - if c.verbose || c.debug { - c.logf("→ GET %s", u.String()) - } - - resp, err := c.http.Do(req) - if err != nil { - // Unwrap url.Error to check for auth errors from CheckRedirect. - // Auth errors (session expiry) must not be retried. - var urlErr *url.Error - if errors.As(err, &urlErr) { - var authErr *types.LnkError - if errors.As(urlErr.Err, &authErr) && authErr.Code == types.ExitAuth { - return nil, authErr - } - if !urlErr.Timeout() { - return nil, types.NetworkError("GraphQL request failed", err) - } - } - lastErr = types.NetworkError("HTTP request failed", err) - continue - } - defer resp.Body.Close() - - if c.verbose || c.debug { - c.logf("← %d %s", resp.StatusCode, resp.Status) - } - - if resp.StatusCode == http.StatusTooManyRequests { - lastErr = types.RateLimitError() - continue - } - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return nil, fmt.Errorf("api: unexpected status %d: %s", resp.StatusCode, string(body)) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, types.NetworkError("reading response body", err) - } - - if c.debug { - c.logf("body: %s", string(body)) - } - - return json.RawMessage(body), nil - } - - return nil, lastErr + query := "queryId=" + url.QueryEscape(queryID) + "&queryName=" + url.QueryEscape(queryName) + "&variables=" + varStr + return c.readJSON(context.Background(), graphQLReadOperation, baseURL+graphQLPath+"?"+query) } -// injectHeaders attaches all required LinkedIn API headers to req. func (c *Client) injectHeaders(req *http.Request) { csrf := c.creds.CSRFToken bcookie := c.creds.BCookie if bcookie == "" { bcookie = "v=2&00000000-0000-0000-0000-000000000000" } - - cookie := fmt.Sprintf( - "li_at=%s; JSESSIONID=ajax:%s; bcookie=%s; liap=true; lang=v=2&lang=en_US", - c.creds.LiAt, csrf, bcookie, - ) - + cookie := fmt.Sprintf("li_at=%s; JSESSIONID=ajax:%s; bcookie=%s; liap=true; lang=v=2&lang=en_US", c.creds.LiAt, csrf, bcookie) + track, _ := json.Marshal(trackingHeader{ + OSName: "Android OS", OSVersion: "36", ClientVersion: legacyAppVersion, + Model: "samsung_SM-S901E", DisplayDensity: 2.625, DisplayWidth: 1080, + DisplayHeight: 2340, TimezoneOffset: 2, InterfaceLocale: "en_US", + }) req.Header.Set("cookie", cookie) req.Header.Set("csrf-token", "ajax:"+csrf) req.Header.Set("x-restli-protocol-version", "2.0.0") req.Header.Set("x-li-lang", "en_US") - req.Header.Set("x-li-track", trackHeader) + req.Header.Set("x-li-track", string(track)) req.Header.Set("user-agent", userAgent) req.Header.Set("accept", "application/json") req.Header.Set("accept-language", "en-US,en;q=0.9") } -// logf writes a diagnostic message to errOut when verbose/debug is on. func (c *Client) logf(format string, args ...interface{}) { fmt.Fprintf(c.errOut, "[lnk] "+format+"\n", args...) } diff --git a/internal/api/post.go b/internal/api/post.go index 4520130..ea7c431 100644 --- a/internal/api/post.go +++ b/internal/api/post.go @@ -1,125 +1,26 @@ package api import ( - "bytes" + "context" "encoding/json" - "fmt" - "io" - "net/http" - - "github.com/yashiels/linkedin-cli/internal/types" ) -// PostJSON sends a JSON-encoded body to the given Voyager API path (relative -// to baseURL, e.g. "/voyager/api/voyagerJobsDashOnsiteApplyApplication") -// with optional query parameters appended to the URL. -// -// queryParams is a map of key→value pairs appended as "?key=value" after -// the path. Pass nil for no query params. -// -// Returns the raw JSON response body or an error. func (c *Client) PostJSON(path string, queryParams map[string]string, body interface{}) (json.RawMessage, error) { - data, err := json.Marshal(body) + u, err := safeDestination(baseURL + path) if err != nil { - return nil, fmt.Errorf("api: cannot marshal request body: %w", err) + return nil, err } - - target := baseURL + path if len(queryParams) > 0 { - first := true + params := u.Query() for k, v := range queryParams { - if first { - target += "?" + k + "=" + v - first = false - } else { - target += "&" + k + "=" + v - } + params.Set(k, v) } + u.RawQuery = params.Encode() } - - req, err := http.NewRequest(http.MethodPost, target, bytes.NewReader(data)) - if err != nil { - return nil, fmt.Errorf("api: cannot build POST request: %w", err) - } - - c.injectHeaders(req) - req.Header.Set("content-type", "application/json") - - if c.verbose || c.debug { - c.logf("→ POST %s", target) - if c.debug { - c.logf("body: %s", string(data)) - } - } - - resp, err := c.http.Do(req) - if err != nil { - return nil, types.NetworkError("HTTP POST request failed", err) - } - defer resp.Body.Close() - - if c.verbose || c.debug { - c.logf("← %d %s", resp.StatusCode, resp.Status) - } - - if resp.StatusCode == http.StatusTooManyRequests { - return nil, types.RateLimitError() - } - - // 2xx is success; LinkedIn sometimes returns 201 or 204 for creates. - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return nil, fmt.Errorf("api: unexpected status %d: %s", resp.StatusCode, string(errBody)) - } - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - return nil, types.NetworkError("reading POST response body", err) - } - - if c.debug && len(respBody) > 0 { - c.logf("response body: %s", string(respBody)) - } - - if len(respBody) == 0 { - return json.RawMessage("{}"), nil - } - - return json.RawMessage(respBody), nil + return c.mutateJSON(context.Background(), postOperation, u.String(), body, false) } -// DeleteResource sends a DELETE request to the given Voyager API path. -// Returns an error on non-2xx responses. func (c *Client) DeleteResource(path string) error { - req, err := http.NewRequest(http.MethodDelete, baseURL+path, nil) - if err != nil { - return fmt.Errorf("api: cannot build DELETE request: %w", err) - } - - c.injectHeaders(req) - - if c.verbose || c.debug { - c.logf("→ DELETE %s", baseURL+path) - } - - resp, err := c.http.Do(req) - if err != nil { - return types.NetworkError("HTTP DELETE request failed", err) - } - defer resp.Body.Close() - - if c.verbose || c.debug { - c.logf("← %d %s", resp.StatusCode, resp.Status) - } - - if resp.StatusCode == http.StatusTooManyRequests { - return types.RateLimitError() - } - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return fmt.Errorf("api: DELETE returned status %d: %s", resp.StatusCode, string(body)) - } - - return nil + _, err := c.mutateJSON(context.Background(), deleteOperation, baseURL+path, nil, false) + return err } diff --git a/internal/api/profile.go b/internal/api/profile.go index dc1a9b6..8dfb6e5 100644 --- a/internal/api/profile.go +++ b/internal/api/profile.go @@ -1,17 +1,9 @@ -// Package api — profile.go provides the dash profiles REST endpoint client. package api import ( + "context" "encoding/json" - "errors" - "fmt" - "io" - "math" - "net/http" "net/url" - "time" - - "github.com/yashiels/linkedin-cli/internal/types" ) const ( @@ -19,90 +11,11 @@ const ( dashDecoration = "com.linkedin.voyager.dash.deco.identity.profile.FullProfileWithEntities-109" ) -// GetProfileByIdentity fetches a complete LinkedIn profile using the dash profiles -// REST endpoint. This endpoint is the current working replacement for the -// deprecated /voyager/api/identity/profiles/ endpoint and returns -// full profile data including education, experience, skills, certifications, -// and honors in a single request. -// -// Endpoint: -// -// GET /voyager/api/identity/dash/profiles -// ?q=memberIdentity -// &memberIdentity= -// &decorationId=com.linkedin.voyager.dash.deco.identity.profile.FullProfileWithEntities-109 -// -// Response: {"elements": [{...full profile object...}]} func (c *Client) GetProfileByIdentity(vanityName string) (json.RawMessage, error) { params := url.Values{ "q": {"memberIdentity"}, "memberIdentity": {vanityName}, "decorationId": {dashDecoration}, } - u, _ := url.Parse(baseURL + dashProfilesPath) - u.RawQuery = params.Encode() - - var lastErr error - for attempt := 0; attempt <= maxRetries; attempt++ { - if attempt > 0 { - wait := time.Duration(math.Pow(2, float64(attempt-1))) * baseBackoff - c.logf("profile: rate limited, retrying in %s (attempt %d/%d)", wait, attempt, maxRetries) - time.Sleep(wait) - } - - req, err := http.NewRequest(http.MethodGet, u.String(), nil) - if err != nil { - return nil, fmt.Errorf("api: cannot build dash profile request: %w", err) - } - // injectHeaders sets accept: application/json; do NOT override to normalized JSON - // because the dash endpoint uses standard JSON format with elements[]. - c.injectHeaders(req) - - if c.verbose || c.debug { - c.logf("→ GET %s", u.String()) - } - - resp, err := c.http.Do(req) - if err != nil { - // Unwrap url.Error to check for auth errors from CheckRedirect. - // Auth errors (session expiry) must not be retried. - var urlErr *url.Error - if errors.As(err, &urlErr) { - var authErr *types.LnkError - if errors.As(urlErr.Err, &authErr) && authErr.Code == types.ExitAuth { - return nil, authErr - } - if !urlErr.Timeout() { - return nil, types.NetworkError("HTTP GET dash profile failed", err) - } - } - lastErr = types.NetworkError("HTTP GET dash profile failed", err) - continue - } - body, readErr := io.ReadAll(resp.Body) - resp.Body.Close() - if readErr != nil { - lastErr = types.NetworkError("reading dash profile response body", readErr) - continue - } - - if c.verbose || c.debug { - c.logf("← %d %s", resp.StatusCode, resp.Status) - } - if c.debug { - c.logf("body: %s", string(body)) - } - - switch resp.StatusCode { - case http.StatusOK: - return json.RawMessage(body), nil - case http.StatusTooManyRequests: - lastErr = types.RateLimitError() - continue - default: - return nil, fmt.Errorf("api: dash profile returned %d: %s", - resp.StatusCode, truncateBody(body)) - } - } - return nil, lastErr + return c.readJSON(context.Background(), readOperation, baseURL+dashProfilesPath+"?"+params.Encode()) } diff --git a/internal/api/request.go b/internal/api/request.go new file mode 100644 index 0000000..9f09acc --- /dev/null +++ b/internal/api/request.go @@ -0,0 +1,230 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/yashiels/linkedin-cli/internal/types" +) + +type operation uint8 + +const ( + readOperation operation = iota + graphQLReadOperation + normalizedReadOperation + postOperation + deleteOperation +) + +func linkedinOrigin(u *url.URL) bool { + return u.Scheme == "https" && u.Host == "www.linkedin.com" && u.User == nil && u.Opaque == "" +} + +func safeDestination(target string) (*url.URL, error) { + u, err := url.Parse(target) + if err != nil || !linkedinOrigin(u) || u.Fragment != "" || u.RawFragment != "" || strings.Contains(target, "#") { + return nil, types.NetworkError("api: rejected LinkedIn destination", nil) + } + p := u.EscapedPath() + for range 5 { + decoded, err := url.PathUnescape(p) + if err != nil { + return nil, types.NetworkError("api: rejected LinkedIn path", nil) + } + if strings.ContainsAny(decoded, "\\?#\r\n\t\x00") || strings.Contains(decoded, "//") || !strings.HasPrefix(decoded, "/voyager/api/") || path.Clean(decoded) != strings.TrimSuffix(decoded, "/") { + return nil, types.NetworkError("api: rejected LinkedIn path", nil) + } + if decoded == p { + return u, nil + } + if strings.Count(decoded, "/") != strings.Count(p, "/") { + return nil, types.NetworkError("api: rejected encoded path separator", nil) + } + p = decoded + } + return nil, types.NetworkError("api: rejected LinkedIn path encoding", nil) +} + +func authRedirect(origin *url.URL, location string) bool { + u, err := origin.Parse(location) + if err != nil || !linkedinOrigin(u) { + return false + } + p := u.EscapedPath() + for range 5 { + decoded, err := url.PathUnescape(p) + if err != nil { + return false + } + if decoded == p { + break + } + p = decoded + } + p = path.Clean(p) + for _, prefix := range []string{"/login", "/uas/login", "/authwall", "/checkpoint/challenge"} { + if p == prefix || strings.HasPrefix(p, prefix+"/") { + return true + } + } + return false +} + +type responseBody struct { + io.ReadCloser + cancel context.CancelFunc +} + +func (b *responseBody) Close() error { + err := b.ReadCloser.Close() + b.cancel() + return err +} + +func (c *Client) send(ctx context.Context, op operation, target string, payload []byte) (*http.Response, error) { + u, err := safeDestination(target) + if err != nil { + return nil, err + } + method := http.MethodGet + switch op { + case readOperation, graphQLReadOperation, normalizedReadOperation: + case postOperation: + method = http.MethodPost + case deleteOperation: + method = http.MethodDelete + default: + return nil, types.NetworkError("api: invalid operation", nil) + } + timeout := c.http.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + requestCtx, cancel := context.WithTimeout(ctx, timeout) + req, err := http.NewRequestWithContext(requestCtx, method, u.String(), bytes.NewReader(payload)) + if err != nil { + cancel() + return nil, types.NetworkError("api: cannot build LinkedIn request", nil) + } + c.injectHeaders(req) + if op == normalizedReadOperation { + req.Header.Set("Accept", "application/vnd.linkedin.normalized+json+2.1") + } + if op == postOperation { + req.Header.Set("Content-Type", "application/json") + } + if op == postOperation || op == deleteOperation { + req.GetBody = nil + } + if c.verbose || c.debug { + c.logf("→ %s LinkedIn API", method) + } + resp, err := c.http.Do(req) + if err != nil { + cancel() + var timed net.Error + if errors.As(err, &timed) && timed.Timeout() { + return nil, types.NetworkError("LinkedIn request timed out", context.DeadlineExceeded) + } + return nil, types.NetworkError("LinkedIn request failed", nil) + } + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + resp.Body.Close() + cancel() + if authRedirect(u, resp.Header.Get("Location")) { + return nil, types.AuthError("session expired — run 'lnk auth login' to refresh") + } + return nil, types.NetworkError("LinkedIn redirect rejected", nil) + } + resp.Body = &responseBody{ReadCloser: resp.Body, cancel: cancel} + if c.verbose || c.debug { + c.logf("← %d LinkedIn API", resp.StatusCode) + } + return resp, nil +} + +func (c *Client) readJSON(ctx context.Context, op operation, target string) (json.RawMessage, error) { + var lastErr error + for attempt := 0; attempt <= maxRetries; attempt++ { + if attempt > 0 { + wait := baseBackoff * time.Duration(1<<(attempt-1)) + c.logf("retrying LinkedIn read in %s (attempt %d/%d)", wait, attempt, maxRetries) + timer := time.NewTimer(wait) + select { + case <-ctx.Done(): + timer.Stop() + return nil, types.NetworkError("LinkedIn read canceled", nil) + case <-timer.C: + } + } + resp, err := c.send(ctx, op, target, nil) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + lastErr = err + continue + } + return nil, err + } + if resp.StatusCode == http.StatusTooManyRequests { + resp.Body.Close() + lastErr = types.RateLimitError() + continue + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, fmt.Errorf("api: unexpected status %d", resp.StatusCode) + } + body, readErr := io.ReadAll(resp.Body) + resp.Body.Close() + if readErr != nil { + lastErr = types.NetworkError("reading LinkedIn response failed", nil) + if op == graphQLReadOperation { + return nil, lastErr + } + continue + } + return json.RawMessage(body), nil + } + return nil, lastErr +} + +func (c *Client) mutateJSON(ctx context.Context, op operation, target string, payload interface{}, strictDelete bool) (json.RawMessage, error) { + var data []byte + if op == postOperation { + var err error + data, err = json.Marshal(payload) + if err != nil { + return nil, errors.New("api: cannot marshal payload") + } + } + resp, err := c.send(ctx, op, target, data) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusTooManyRequests { + return nil, types.RateLimitError() + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 || (strictDelete && resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent) { + return nil, fmt.Errorf("api: unexpected status %d", resp.StatusCode) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NetworkError("reading LinkedIn response failed", nil) + } + if len(body) == 0 { + body = []byte("{}") + } + return json.RawMessage(body), nil +} diff --git a/internal/api/request_test.go b/internal/api/request_test.go new file mode 100644 index 0000000..228b107 --- /dev/null +++ b/internal/api/request_test.go @@ -0,0 +1,495 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "io" + "io/fs" + "net/http" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "github.com/yashiels/linkedin-cli/internal/auth" + "github.com/yashiels/linkedin-cli/internal/types" +) + +type wrapperCase struct { + name string + method string + accept string + call func(*Client) error +} + +func requestWrappers() []wrapperCase { + return []wrapperCase{ + {"graphql", "GET", "application/json", func(c *Client) error { + _, err := c.QueryGraphQL("fixture", "fixture.1", map[string]string{"key": "value"}) + return err + }}, + {"get", "GET", "application/vnd.linkedin.normalized+json+2.1", func(c *Client) error { _, err := c.Get("/voyager/api/me", nil); return err }}, + {"profile", "GET", "application/json", func(c *Client) error { _, err := c.GetProfileByIdentity("fixture"); return err }}, + {"ping", "GET", "application/json", func(c *Client) error { _, err := c.Ping(); return err }}, + {"post", "POST", "application/json", func(c *Client) error { + _, err := c.Post("/voyager/api/fixture", map[string]string{"test": "value"}) + return err + }}, + {"postJSON", "POST", "application/json", func(c *Client) error { + _, err := c.PostJSON("/voyager/api/fixture", nil, map[string]string{"test": "value"}) + return err + }}, + {"delete", "DELETE", "application/json", func(c *Client) error { return c.Delete("/voyager/api/fixture") }}, + {"deleteResource", "DELETE", "application/json", func(c *Client) error { return c.DeleteResource("/voyager/api/fixture") }}, + } +} + +func fixtureResponse(code int, location string) *http.Response { + h := make(http.Header) + if location != "" { + h.Set("Location", location) + } + return &http.Response{StatusCode: code, Header: h, Body: io.NopCloser(strings.NewReader(`{}`))} +} + +func fixtureClient(rt http.RoundTripper, opts ...Option) *Client { + return New(auth.Credentials{LiAt: "synthetic-li-at", CSRFToken: "synthetic-csrf"}, append([]Option{WithHTTPClient(&http.Client{Transport: rt})}, opts...)...) +} + +func checkTracking(t *testing.T, raw string) { + t.Helper() + d := json.NewDecoder(strings.NewReader(raw)) + if tok, err := d.Token(); err != nil || tok != json.Delim('{') { + t.Fatalf("invalid tracking JSON: %q", raw) + } + values := map[string]interface{}{} + for d.More() { + key, err := d.Token() + if err != nil { + t.Fatal(err) + } + name := key.(string) + if _, exists := values[name]; exists { + t.Errorf("duplicate tracking key %q", name) + } + var value interface{} + if err := d.Decode(&value); err != nil { + t.Fatal(err) + } + values[name] = value + } + if _, err := d.Token(); err != nil { + t.Fatal(err) + } + if _, err := d.Token(); err != io.EOF { + t.Fatal("trailing tracking JSON") + } + if values["osVersion"] != "36" || values["clientVersion"] != "4.1.1209" { + t.Errorf("incorrect tracking versions: %v", values) + } +} + +func TestRequestWrappersHeaders(t *testing.T) { + for _, tc := range requestWrappers() { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + c := fixtureClient(roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + if req.Method != tc.method || req.Header.Get("Accept") != tc.accept { + t.Errorf("method/accept: %s %s", req.Method, req.Header.Get("Accept")) + } + if req.URL.Scheme != "https" || req.URL.Host != "www.linkedin.com" || !strings.HasPrefix(req.URL.Path, "/voyager/api/") { + t.Error("wrong destination") + } + if !strings.Contains(req.Header.Get("Cookie"), "li_at=synthetic-li-at") || req.Header.Get("Csrf-Token") != "ajax:synthetic-csrf" { + t.Error("missing credentials") + } + if req.Header.Get("X-Restli-Protocol-Version") != "2.0.0" { + t.Error("missing RestLi version") + } + checkTracking(t, req.Header.Get("X-LI-Track")) + if !strings.HasPrefix(req.UserAgent(), "com.linkedin.android/211700 ") { + t.Error("incorrect legacy build") + } + if req.Method != "GET" && (req.GetBody != nil || req.Header.Get("Idempotency-Key") != "" || req.Header.Get("X-Idempotency-Key") != "") { + t.Error("mutation is replayable") + } + if _, ok := req.Context().Deadline(); !ok { + t.Error("request has no bounded context") + } + return fixtureResponse(200, ""), nil + })) + if err := tc.call(c); err != nil { + t.Fatal(err) + } + if calls != 1 { + t.Fatalf("admissions=%d", calls) + } + }) + } +} + +func TestRequestRedirects(t *testing.T) { + locations := []struct { + target string + auth bool + }{ + {"https://external.invalid/private?token=secret-target", false}, + {"/voyager/api/other?token=secret-target", false}, + {"/voyager/api/me", false}, + {"/login?token=secret-target", true}, + {"/uas/login/step", true}, + {"/authwall", true}, + {"/checkpoint/challenge/step", true}, + {"/%6cogin", true}, + {"/voyager/api/../../authwall", true}, + {"/voyager/api/%2e%2e/%2e%2e/checkpoint%2fchallenge", true}, + {"/%2561uthwall", true}, + {"/login-other", false}, + {"https://external.invalid/login", false}, + {"https://www.linkedin.com:443/login", false}, + {"http://www.linkedin.com/login", false}, + {"https://user@www.linkedin.com/login", false}, + {"https://%zz", false}, + } + for _, tc := range requestWrappers() { + for _, loc := range locations { + for _, status := range []int{301, 302, 303, 307, 308} { + t.Run(fmt.Sprintf("%s/%d/%s", tc.name, status, loc.target), func(t *testing.T) { + calls := 0 + var logs bytes.Buffer + c := fixtureClient(roundTripFunc(func(*http.Request) (*http.Response, error) { + calls++ + if calls > 1 { + return fixtureResponse(200, ""), nil + } + return fixtureResponse(status, loc.target), nil + }), WithDebug(true), WithVerbose(true), WithErrWriter(&logs)) + err := tc.call(c) + if err == nil { + t.Error("redirect accepted") + } + var coded *types.LnkError + expectedCode := types.ExitNetwork + if loc.auth { + expectedCode = types.ExitAuth + } + if !errors.As(err, &coded) || coded.Code != expectedCode { + t.Errorf("expected exit %d, got %v", expectedCode, err) + } + if calls != 1 { + t.Errorf("admissions=%d", calls) + } + if strings.Contains(fmt.Sprint(err)+logs.String(), "secret-target") { + t.Error("redirect target leaked") + } + }) + } + } + } +} + +func TestRequestClientOptions(t *testing.T) { + for _, timeoutFirst := range []bool{false, true} { + t.Run(fmt.Sprint(timeoutFirst), func(t *testing.T) { + redirectCalls := 0 + caller := &http.Client{Timeout: 17 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { + redirectCalls++ + return errors.New("caller redirect callback") + }, Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return fixtureResponse(302, "/login"), nil })} + before := *caller + opts := []Option{WithHTTPClient(caller), WithTimeout(2 * time.Second)} + if timeoutFirst { + opts[0], opts[1] = opts[1], opts[0] + } + c := New(auth.Credentials{}, opts...) + if c.http == caller || c.http.Timeout != 2*time.Second { + t.Error("client ownership or explicit timeout lost") + } + if caller.Timeout != before.Timeout || reflect.ValueOf(caller.CheckRedirect).Pointer() != reflect.ValueOf(before.CheckRedirect).Pointer() || reflect.ValueOf(caller.Transport).Pointer() != reflect.ValueOf(before.Transport).Pointer() { + t.Error("caller client mutated") + } + _, err := c.Ping() + var coded *types.LnkError + if !errors.As(err, &coded) || coded.Code != types.ExitAuth || redirectCalls != 0 { + t.Errorf("redirect policy bypass: %v, callbacks=%d", err, redirectCalls) + } + }) + } +} + +func TestRequestMutationFailures(t *testing.T) { + for _, tc := range requestWrappers() { + if tc.method == "GET" { + continue + } + t.Run(tc.name, func(t *testing.T) { + calls := 0 + c := fixtureClient(roundTripFunc(func(*http.Request) (*http.Response, error) { + calls++ + return nil, errors.New("transport synthetic-secret https://external.invalid/private") + })) + err := tc.call(c) + if err == nil || calls != 1 { + t.Fatalf("error=%v admissions=%d", err, calls) + } + if strings.Contains(err.Error(), "synthetic-secret") || strings.Contains(err.Error(), "https://") { + t.Errorf("transport detail leaked: %v", err) + } + }) + } +} + +func TestRequestSendSites(t *testing.T) { + root := filepath.Clean("../..") + allowed := map[string]bool{"internal/api/request.go:send": false, "internal/api/application.go:Check": false} + err := filepath.WalkDir(root, func(filename string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() && (entry.Name() == ".git" || entry.Name() == ".worktrees" || entry.Name() == "vendor") { + return filepath.SkipDir + } + if entry.IsDir() || !strings.HasSuffix(filename, ".go") || strings.HasSuffix(filename, "_test.go") { + return nil + } + file, err := parser.ParseFile(token.NewFileSet(), filename, nil, 0) + if err != nil { + return err + } + httpPackages := map[string]bool{} + for _, imp := range file.Imports { + name, err := strconv.Unquote(imp.Path.Value) + if err != nil { + return err + } + if name == "net/http" { + alias := "http" + if imp.Name != nil { + alias = imp.Name.Name + } + httpPackages[alias] = true + } + } + ast.Inspect(file, func(node ast.Node) bool { + sel, ok := node.(*ast.SelectorExpr) + if !ok { + return true + } + isSend := sel.Sel.Name == "Do" || sel.Sel.Name == "RoundTrip" + if id, ok := sel.X.(*ast.Ident); ok && httpPackages[id.Name] { + switch sel.Sel.Name { + case "Get", "Head", "Post", "PostForm": + isSend = true + } + } + if !isSend { + return true + } + owner := "" + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Pos() <= sel.Pos() && sel.End() <= fn.End() { + owner = fn.Name.Name + } + } + rel, err := filepath.Rel(root, filename) + if err != nil { + t.Fatal(err) + } + key := filepath.ToSlash(rel) + ":" + owner + if seen, exists := allowed[key]; !exists || seen { + t.Errorf("unexpected HTTP send site %s", key) + } else { + allowed[key] = true + } + return true + }) + return nil + }) + if err != nil { + t.Fatal(err) + } + for key, found := range allowed { + if !found { + t.Errorf("missing expected send site %s", key) + } + } +} + +func TestRequestDestinations(t *testing.T) { + invalid := []string{ + "http://www.linkedin.com/voyager/api/me", + "https://linkedin.com/voyager/api/me", + "https://www.linkedin.com.evil.invalid/voyager/api/me", + "https://www.linkedin.com:443/voyager/api/me", + "https://www.linkedin.com:/voyager/api/me", + "https://user:synthetic-secret@www.linkedin.com/voyager/api/me", + "https://www.linkedin.com/voyager/api/me#synthetic-secret", + "https://www.linkedin.com/voyager/api/me#", + "https://www.linkedin.com/voyager/api/me%09", + "https://www.linkedin.com/voyager/api/../../login", + "https://www.linkedin.com/voyager/api/%2e%2e/me", + "https://www.linkedin.com/voyager/api/%252e%252e/me", + "https://www.linkedin.com/voyager/api/x%2fy", + "https://www.linkedin.com/voyager/api/x%252fy", + "https://www.linkedin.com/voyager/api/x%5cy", + "https://www.linkedin.com/voyager/api//me", + "https://www.linkedin.com/voyager/api/./me", + "https://www.linkedin.com/voyager/api/me%3fsecret", + "https://www.linkedin.com/voyager/api/me%23secret", + "https://www.linkedin.com/voyager/api/me%00", + "https://www.linkedin.com/voyager/apix/me", + "https://www.linkedin.com/login", + "https://www.linkedin.com/voyager/api/%zz", + "https://external.invalid/voyager/api/me", + "//www.linkedin.com/voyager/api/me", + } + for _, target := range invalid { + for _, op := range []operation{readOperation, graphQLReadOperation, normalizedReadOperation, postOperation, deleteOperation} { + t.Run(fmt.Sprintf("%d/%s", op, target), func(t *testing.T) { + calls := 0 + var logs bytes.Buffer + c := fixtureClient(roundTripFunc(func(*http.Request) (*http.Response, error) { calls++; return fixtureResponse(200, ""), nil }), WithDebug(true), WithErrWriter(&logs)) + _, err := c.send(context.Background(), op, target, nil) + if err == nil || calls != 0 { + t.Fatalf("error=%v admissions=%d", err, calls) + } + if strings.Contains(err.Error()+logs.String(), target) || strings.Contains(err.Error()+logs.String(), "synthetic-secret") { + t.Error("destination leaked") + } + }) + } + } + for _, target := range []string{baseURL + "/voyager/api/me", baseURL + "/voyager/api/identity/profiles/fixture-name", baseURL + "/voyager/api/subscriptions/(jobPosting:urn%3Ali%3Afsd_jobPosting%3A123)", baseURL + "/voyager/api/me?value=https%3A%2F%2Fexternal.invalid"} { + if _, err := safeDestination(target); err != nil { + t.Errorf("safe destination rejected: %s: %v", target, err) + } + } +} + +func TestRequestPublicPathValidation(t *testing.T) { + for _, target := range []string{"/login", "/voyager/api/../login", "/voyager/api/%2e%2e/login", "//external.invalid/voyager/api/me", "/voyager/api/me#synthetic-secret", "https://external.invalid/voyager/api/me"} { + c := fixtureClient(roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Error("unsafe wrapper reached transport") + return fixtureResponse(200, ""), nil + })) + if _, err := c.Get(target, nil); err == nil { + t.Error("Get accepted unsafe path") + } + if _, err := c.Post(target, nil); err == nil { + t.Error("Post accepted unsafe path") + } + if _, err := c.PostJSON(target, nil, nil); err == nil { + t.Error("PostJSON accepted unsafe path") + } + if err := c.Delete(target); err == nil { + t.Error("Delete accepted unsafe path") + } + if err := c.DeleteResource(target); err == nil { + t.Error("DeleteResource accepted unsafe path") + } + } +} + +func TestRequestQueryEncoding(t *testing.T) { + params := map[string]string{"action": "submit & continue?x=1#value", "key&=": "a+b / snow ☃"} + calls := 0 + c := fixtureClient(roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + for key, value := range params { + if req.URL.Query().Get(key) != value { + t.Errorf("query value corrupted for %q", key) + } + } + if req.URL.Fragment != "" { + t.Error("query created fragment") + } + return fixtureResponse(201, ""), nil + })) + if _, err := c.PostJSON("/voyager/api/fixture", params, nil); err != nil { + t.Fatal(err) + } + if calls != 1 { + t.Fatalf("admissions=%d", calls) + } +} + +func TestRequestContextLifecycle(t *testing.T) { + var request *http.Request + c := fixtureClient(roundTripFunc(func(req *http.Request) (*http.Response, error) { request = req; return fixtureResponse(200, ""), nil })) + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + response, err := c.send(ctx, readOperation, baseURL+"/voyager/api/me", nil) + if err != nil { + t.Fatal(err) + } + if request.Context().Err() != nil { + t.Fatal("context canceled before body consumed") + } + response.Body.Close() + if !errors.Is(request.Context().Err(), context.Canceled) { + t.Fatal("body close did not release context") + } +} + +func TestRequestMutationRateLimits(t *testing.T) { + for _, tc := range requestWrappers() { + if tc.method == "GET" { + continue + } + t.Run(tc.name, func(t *testing.T) { + calls := 0 + c := fixtureClient(roundTripFunc(func(*http.Request) (*http.Response, error) { calls++; return fixtureResponse(429, ""), nil })) + err := tc.call(c) + var coded *types.LnkError + if !errors.As(err, &coded) || coded.Code != types.ExitRateLimit || calls != 1 { + t.Fatalf("error=%v admissions=%d", err, calls) + } + }) + } +} + +func TestRequestClientDefaults(t *testing.T) { + caller := &http.Client{Timeout: 7 * time.Second} + for _, c := range []*Client{New(auth.Credentials{}), New(auth.Credentials{}, WithHTTPClient(nil)), New(auth.Credentials{}, WithHTTPClient(caller))} { + if c.http.CheckRedirect == nil { + t.Fatal("missing redirect policy") + } + } + c := New(auth.Credentials{}, WithHTTPClient(caller)) + if c.http == caller || c.http.Timeout != caller.Timeout || caller.CheckRedirect != nil { + t.Fatal("caller settings not preserved") + } +} + +func TestRequestReadRateLimitCompatibility(t *testing.T) { + for _, tc := range requestWrappers() { + if tc.name == "ping" || tc.method != "GET" { + continue + } + t.Run(tc.name, func(t *testing.T) { + calls := 0 + c := fixtureClient(roundTripFunc(func(*http.Request) (*http.Response, error) { + calls++ + if calls == 1 { + return fixtureResponse(429, ""), nil + } + return fixtureResponse(200, ""), nil + })) + if err := tc.call(c); err != nil { + t.Fatal(err) + } + if calls != 2 { + t.Fatalf("admissions=%d", calls) + } + }) + } +} diff --git a/internal/api/rest.go b/internal/api/rest.go index d48177e..39653a9 100644 --- a/internal/api/rest.go +++ b/internal/api/rest.go @@ -1,193 +1,51 @@ -// rest.go extends Client with generic REST GET/POST/DELETE methods for -// LinkedIn's non-GraphQL voyager endpoints. package api import ( - "bytes" + "context" "encoding/json" - "errors" "fmt" "io" - "math" "net/http" "net/url" - "time" "github.com/yashiels/linkedin-cli/internal/types" ) -// Get performs a retried GET against a LinkedIn REST path (not GraphQL). -// path should start with "/" e.g. "/voyager/api/me". -// params are appended as query-string key/value pairs. func (c *Client) Get(path string, params url.Values) (json.RawMessage, error) { - u, _ := url.Parse(baseURL + path) + u, err := safeDestination(baseURL + path) + if err != nil { + return nil, err + } if len(params) > 0 { u.RawQuery = params.Encode() } - - var lastErr error - for attempt := 0; attempt <= maxRetries; attempt++ { - if attempt > 0 { - wait := time.Duration(math.Pow(2, float64(attempt-1))) * baseBackoff - c.logf("rate limited, retrying in %s (attempt %d/%d)", wait, attempt, maxRetries) - time.Sleep(wait) - } - - req, err := http.NewRequest(http.MethodGet, u.String(), nil) - if err != nil { - return nil, fmt.Errorf("api: cannot build request: %w", err) - } - c.injectHeaders(req) - // Prefer structured JSON responses. - req.Header.Set("accept", "application/vnd.linkedin.normalized+json+2.1") - - if c.verbose || c.debug { - c.logf("→ GET %s", u.String()) - } - - resp, err := c.http.Do(req) - if err != nil { - // Unwrap url.Error to check for auth errors from CheckRedirect. - // Auth errors (session expiry) must not be retried. - var urlErr *url.Error - if errors.As(err, &urlErr) { - var authErr *types.LnkError - if errors.As(urlErr.Err, &authErr) && authErr.Code == types.ExitAuth { - return nil, authErr - } - if !urlErr.Timeout() { - return nil, types.NetworkError("HTTP GET failed", err) - } - } - lastErr = types.NetworkError("HTTP GET failed", err) - continue - } - body, readErr := io.ReadAll(resp.Body) - resp.Body.Close() - if readErr != nil { - lastErr = types.NetworkError("reading response body", readErr) - continue - } - - if c.verbose || c.debug { - c.logf("← %d %s", resp.StatusCode, resp.Status) - } - if c.debug { - c.logf("body: %s", string(body)) - } - - switch resp.StatusCode { - case http.StatusOK: - return json.RawMessage(body), nil - case http.StatusTooManyRequests: - lastErr = types.RateLimitError() - continue - default: - return nil, fmt.Errorf("api: unexpected status %d: %s", - resp.StatusCode, truncateBody(body)) - } - } - return nil, lastErr + return c.readJSON(context.Background(), normalizedReadOperation, u.String()) } -// Post performs a JSON POST to a LinkedIn REST path. func (c *Client) Post(path string, payload interface{}) (json.RawMessage, error) { - data, err := json.Marshal(payload) - if err != nil { - return nil, fmt.Errorf("api: cannot marshal payload: %w", err) - } - - req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(data)) - if err != nil { - return nil, fmt.Errorf("api: cannot build request: %w", err) - } - c.injectHeaders(req) - req.Header.Set("content-type", "application/json") - - if c.verbose || c.debug { - c.logf("→ POST %s", baseURL+path) - } - - resp, err := c.http.Do(req) - if err != nil { - return nil, types.NetworkError("HTTP POST failed", err) - } - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - - if c.verbose || c.debug { - c.logf("← %d %s", resp.StatusCode, resp.Status) - } - - if resp.StatusCode == http.StatusTooManyRequests { - return nil, types.RateLimitError() - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("api: unexpected status %d: %s", - resp.StatusCode, truncateBody(body)) - } - if len(body) == 0 { - return json.RawMessage("{}"), nil - } - return json.RawMessage(body), nil + return c.mutateJSON(context.Background(), postOperation, baseURL+path, payload, false) } -// Delete performs a DELETE against a LinkedIn REST path. func (c *Client) Delete(path string) error { - req, err := http.NewRequest(http.MethodDelete, baseURL+path, nil) - if err != nil { - return fmt.Errorf("api: cannot build request: %w", err) - } - c.injectHeaders(req) - - if c.verbose || c.debug { - c.logf("→ DELETE %s", baseURL+path) - } - - resp, err := c.http.Do(req) - if err != nil { - return types.NetworkError("HTTP DELETE failed", err) - } - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - - if c.verbose || c.debug { - c.logf("← %d %s", resp.StatusCode, resp.Status) - } - - if resp.StatusCode == http.StatusTooManyRequests { - return types.RateLimitError() - } - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { - return fmt.Errorf("api: unexpected status %d: %s", - resp.StatusCode, truncateBody(body)) - } - return nil + _, err := c.mutateJSON(context.Background(), deleteOperation, baseURL+path, nil, true) + return err } -// Ping makes a lightweight request to verify API connectivity. -// Returns the datacenter identifier if present in response headers. func (c *Client) Ping() (string, error) { - req, err := http.NewRequest(http.MethodGet, baseURL+"/voyager/api/me", nil) + resp, err := c.send(context.Background(), readOperation, baseURL+"/voyager/api/me", nil) if err != nil { - return "", fmt.Errorf("api: cannot build request: %w", err) + return "", err } - c.injectHeaders(req) - - resp, err := c.http.Do(req) - if err != nil { - return "", types.NetworkError("connectivity check failed", err) + defer resp.Body.Close() + if _, err := io.Copy(io.Discard, resp.Body); err != nil { + return "", types.NetworkError("reading LinkedIn ping failed", nil) } - io.Copy(io.Discard, resp.Body) - resp.Body.Close() - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { return "", types.AuthError("session expired or credentials invalid") } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("api: ping returned %d", resp.StatusCode) } - // Try to extract datacenter from Via or X-LI-Pop headers. if pop := resp.Header.Get("x-li-pop"); pop != "" { return pop, nil } @@ -196,10 +54,3 @@ func (c *Client) Ping() (string, error) { } return "unknown", nil } - -func truncateBody(b []byte) string { - if len(b) > 200 { - return string(b[:200]) + "…" - } - return string(b) -}