diff --git a/internal/appstore/auth_endpoint.go b/internal/appstore/auth_endpoint.go new file mode 100644 index 0000000..af4e8dd --- /dev/null +++ b/internal/appstore/auth_endpoint.go @@ -0,0 +1,68 @@ +package appstore + +import ( + "errors" + "fmt" + "html" + "net/url" + "regexp" + "strings" +) + +var authEndpointURLPattern = regexp.MustCompile(`https?://[^\s"'<>]+`) + +func normalizeAuthEndpoint(endpoints ...string) string { + for _, endpoint := range endpoints { + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" { + continue + } + + normalized := normalizeNativeAuthEndpoint(endpoint) + if normalized != "" { + return normalized + } + + return endpoint + } + + return fmt.Sprintf("https://%s%s", PrivateAuthDomain, PrivateAuthPathNative) +} + +func authEndpointFromResponseError(err error) string { + var decodeErr *ResponseDecodeError + if !errors.As(err, &decodeErr) { + return "" + } + + return authEndpointFromText(strings.Join(append(decodeErr.URLs, decodeErr.Body), " ")) +} + +func authEndpointFromText(text string) string { + text = html.UnescapeString(strings.ReplaceAll(text, `\/`, `/`)) + + matches := authEndpointURLPattern.FindAllString(text, -1) + for _, match := range matches { + if endpoint := normalizeNativeAuthEndpoint(strings.TrimRight(match, ".,;)")); endpoint != "" { + return endpoint + } + } + + return "" +} + +func normalizeNativeAuthEndpoint(endpoint string) string { + parsed, err := url.Parse(endpoint) + if err != nil || parsed.Host != PrivateAuthDomain { + return "" + } + + path := strings.TrimRight(parsed.Path, "/") + if !strings.HasSuffix(path, "/fast") { + path = strings.TrimRight(path, "/") + "/fast" + } + + parsed.Path = path + "/" + + return parsed.String() +} diff --git a/internal/appstore/bag.go b/internal/appstore/bag.go index 69a69bd..a04ca86 100644 --- a/internal/appstore/bag.go +++ b/internal/appstore/bag.go @@ -9,6 +9,7 @@ type bagResult struct { URLBag struct { AuthEndpoint string `plist:"authenticateAccount,omitempty"` } `plist:"urlBag,omitempty"` + AuthEndpoint string `plist:"authenticateAccount,omitempty"` } // bag fetches the App Store bag.xml and returns the authenticate endpoint URL. @@ -32,9 +33,5 @@ func (c *Client) bag() (string, error) { return "", fmt.Errorf("bag: status %d", res.StatusCode) } - if out.URLBag.AuthEndpoint == "" { - return "", fmt.Errorf("bag: no authenticate endpoint") - } - - return out.URLBag.AuthEndpoint, nil + return normalizeAuthEndpoint(out.AuthEndpoint, out.URLBag.AuthEndpoint), nil } diff --git a/internal/appstore/http.go b/internal/appstore/http.go index 893e981..1155547 100644 --- a/internal/appstore/http.go +++ b/internal/appstore/http.go @@ -78,7 +78,13 @@ func (c *Client) send(method, url string, headers map[string]string, body []byte } case formatXML: if _, err := plist.Unmarshal(normalizePlist(data), out); err != nil { - return nil, fmt.Errorf("decode plist: %w", err) + return nil, &ResponseDecodeError{ + Cause: err, + StatusCode: res.StatusCode, + ContentType: res.Header.Get("Content-Type"), + Body: truncateBody(data, 500), + URLs: extractURLs(data), + } } } @@ -124,3 +130,41 @@ func normalizePlist(body []byte) []byte { return n } + +type ResponseDecodeError struct { + Cause error + StatusCode int + ContentType string + Body string + URLs []string +} + +func (e *ResponseDecodeError) Error() string { + return fmt.Sprintf("failed to unmarshal xml: %v", e.Cause) +} + +func (e *ResponseDecodeError) Unwrap() error { + return e.Cause +} + +var urlPattern = regexp.MustCompile(`https?://[^\s"'<>]+`) + +func extractURLs(body []byte) []string { + matches := urlPattern.FindAll(body, -1) + + urls := make([]string, 0, len(matches)) + for _, match := range matches { + urls = append(urls, string(match)) + } + + return urls +} + +func truncateBody(body []byte, max int) string { + trimmed := strings.TrimSpace(string(body)) + if len(trimmed) <= max { + return trimmed + } + + return trimmed[:max] + "..." +} diff --git a/internal/appstore/login.go b/internal/appstore/login.go index aea5d1c..1c0848a 100644 --- a/internal/appstore/login.go +++ b/internal/appstore/login.go @@ -37,7 +37,8 @@ func (c *Client) Login(email, password, authCode string) (*Account, error) { return nil, err } - url := endpoint + authEndpoint := normalizeAuthEndpoint(endpoint) + url := authEndpoint var ( res *http.Response @@ -63,6 +64,13 @@ func (c *Client) Login(email, password, authCode string) (*Account, error) { "Content-Type": "application/x-www-form-urlencoded", }, body, formatXML, &out) if err != nil { + if discoveredEndpoint := authEndpointFromResponseError(err); discoveredEndpoint != "" && discoveredEndpoint != authEndpoint { + authEndpoint = discoveredEndpoint + url = authEndpoint + + continue + } + return nil, fmt.Errorf("login: %w", err) } diff --git a/internal/appstore/types.go b/internal/appstore/types.go index d5b33d0..c2f0efe 100644 --- a/internal/appstore/types.go +++ b/internal/appstore/types.go @@ -52,6 +52,9 @@ const ( downloadPath = "/WebObjects/MZFinance.woa/wa/volumeStoreDownloadProduct" authURL = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate" + PrivateAuthDomain = "auth." + iTunesDomain + PrivateAuthPathNative = "/auth/v1/native/fast/" + hdrStoreFront = "X-Set-Apple-Store-Front" hdrPod = "pod"