diff --git a/.gitignore b/.gitignore index 1d9291ace8..85b3145c8f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ *.so *.dylib *.a +/ipsw # Test binary, build with `go test -c` *.test diff --git a/cmd/ipsw/cmd/appstore/appstore_review.go b/cmd/ipsw/cmd/appstore/appstore_review.go new file mode 100644 index 0000000000..5ba27e5032 --- /dev/null +++ b/cmd/ipsw/cmd/appstore/appstore_review.go @@ -0,0 +1,41 @@ +/* +Copyright © 2024 blacktop + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +package appstore + +import ( + "github.com/spf13/cobra" +) + +func init() { + AppstoreCmd.AddCommand(ASReviewCmd) +} + +// ASReviewCmd represents the appstore review command +var ASReviewCmd = &cobra.Command{ + Use: "review", + Aliases: []string{"r"}, + Short: "List app store reviews", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + cmd.Help() + }, +} diff --git a/cmd/ipsw/cmd/appstore/appstore_review_ls.go b/cmd/ipsw/cmd/appstore/appstore_review_ls.go new file mode 100644 index 0000000000..df079b5c61 --- /dev/null +++ b/cmd/ipsw/cmd/appstore/appstore_review_ls.go @@ -0,0 +1,158 @@ +/* +Copyright © 2024 Kenneth H. Cox + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +package appstore + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/apex/log" + "github.com/blacktop/ipsw/pkg/appstore" + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/spf13/viper" +) + +func init() { + ASReviewCmd.AddCommand(ASReviewListCmd) + + ASReviewListCmd.Flags().String("id", "", "App ID") + ASReviewListCmd.Flags().String("after", "", "Only show responses on or after date, e.g. \"2024-12-22\"") + ASReviewListCmd.Flags().String("since", "", "Only show responses within duration, e.g. \"36h\"") + viper.BindPFlag("appstore.review.ls.id", ASReviewListCmd.Flags().Lookup("id")) + viper.BindPFlag("appstore.review.ls.after", ASReviewListCmd.Flags().Lookup("after")) + viper.BindPFlag("appstore.review.ls.since", ASReviewListCmd.Flags().Lookup("since")) +} + +// ASReviewListCmd represents the appstore review ls command +var ASReviewListCmd = &cobra.Command{ + Use: "ls", + Short: "List reviews", + Args: cobra.NoArgs, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + + if viper.GetBool("verbose") { + log.SetLevel(log.DebugLevel) + } + color.NoColor = viper.GetBool("no-color") + + // flags + id := viper.GetString("appstore.review.ls.id") + after := viper.GetString("appstore.review.ls.after") + since := viper.GetString("appstore.review.ls.since") + // Validate flags + if (viper.GetString("appstore.p8") == "" || viper.GetString("appstore.iss") == "" || viper.GetString("appstore.kid") == "") && viper.GetString("appstore.jwt") == "" { + return fmt.Errorf("you must provide (--p8, --iss and --kid) OR --jwt") + } + if id == "" { + return fmt.Errorf("you must provide --id") + } + if after != "" && since != "" { + return fmt.Errorf("you cannot specify both `--after` and `--since`") + } + afterDate, err := time.Parse("2006-01-02", after) + if after != "" && err != nil { + return err + } + if since != "" { + sinceDuration, err := time.ParseDuration(since) + if err != nil { + return err + } + afterDate = time.Now().Add(-sinceDuration) + } + afterOrSinceFlag := after != "" || since != "" + + as := appstore.NewAppStore( + viper.GetString("appstore.p8"), + viper.GetString("appstore.iss"), + viper.GetString("appstore.kid"), + viper.GetString("appstore.jwt"), + ) + + reviewsResponse, err := as.GetReviews(id) + if err != nil { + return err + } + + // create a map of CustomerReviewResponses by id + responsesById := make(map[string]appstore.CustomerReviewResponse) + for _, response := range reviewsResponse.Responses { + responsesById[response.ID] = response + } + + // display reviews in a format useful for customer service + reviewCount := 0 + responseCount := 0 + for _, review := range reviewsResponse.Reviews { + if time.Time(review.Attributes.Created).Before(afterDate) { + break + } + + // print review summary + reviewCount += 1 + date := review.Attributes.Created.Format("Jan _2 2006") + stars := strings.Repeat("★", review.Attributes.Rating) + hrule := strings.Repeat("-", 19) + fmt.Printf("\n%s\n%s [%-5s] by %s\n", hrule, date, stars, review.Attributes.Reviewer) + fmt.Printf("%s\n", review.Attributes.Title) + + // print review body only if we haven't responded + responseData := review.Relationships.Response.Data + if responseData != nil { + responseCount += 1 + response, exists := responsesById[responseData.ID] + if exists { + fmt.Printf(" (responded %s)\n", response.Attributes.LastModified.Format("Jan _2 2006")) + } else { + fmt.Printf(" (responded)\n") + } + } else { + fmt.Printf(" %s\n", review.Attributes.Body) + } + } + + // print summary, if any reviews were found, or if --verbose was specified + if reviewCount > 0 || viper.GetBool("verbose") { + if afterOrSinceFlag { + fmt.Printf("\n%d reviews since %s\n", reviewCount, afterDate.Format("Jan _2 2006 15:04:05")) + } else { + fmt.Printf("\n%d reviews\n", reviewCount) + } + fmt.Printf("%d responses\n", responseCount) + ratingsUrl := fmt.Sprintf("https://appstoreconnect.apple.com/apps/%s/distribution/activity/ios/ratingsResponses", id) + fmt.Printf("\nTo respond, visit %s", ratingsUrl) + } + + // exit 2 if no new reviews, this will aid scripting + if reviewCount == 0 { + os.Exit(2) + } + + return nil + }, +} diff --git a/pkg/appstore/appstore.go b/pkg/appstore/appstore.go index 57fb1ee266..07eb814212 100644 --- a/pkg/appstore/appstore.go +++ b/pkg/appstore/appstore.go @@ -63,7 +63,11 @@ func (d *Date) UnmarshalJSON(b []byte) error { } t, err := time.Parse("2006-01-02T15:04:05.000+00:00", s) if err != nil { - return err + // If that fails, try parsing without milliseconds + t, err = time.Parse("2006-01-02T15:04:05-07:00", s) + if err != nil { + return err + } } *d = Date(t) return nil @@ -75,6 +79,9 @@ func (d Date) Format(s string) string { t := time.Time(d) return t.Format(s) } +func (d Date) Before(d2 Date) bool { + return time.Time(d).Before(time.Time(d2)) +} type AppStore struct { P8 string diff --git a/pkg/appstore/review.go b/pkg/appstore/review.go new file mode 100644 index 0000000000..303dcbb7e1 --- /dev/null +++ b/pkg/appstore/review.go @@ -0,0 +1,118 @@ +package appstore + +import ( + "crypto/tls" + "encoding/json" + "fmt" + + "net/http" + "net/url" + "strings" + + "github.com/blacktop/ipsw/internal/download" +) + +type CustomerReview struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes struct { + Rating int `json:"rating"` + Title string `json:"title"` + Body string `json:"body"` + Reviewer string `json:"reviewerNickname"` + Created Date `json:"createdDate"` + Territory string `json:"territory"` + } `json:"attributes"` + Relationships struct { + Response struct { + Data *struct { + Type string `json:"type"` + ID string `json:"id"` + } `json:"data"` + Links Links `json:"links"` + } `json:"response"` + } `json:"relationships"` + Links Links `json:"links"` +} + +type CustomerReviewResponse struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes struct { + Body string `json:"responseBody"` + LastModified Date `json:"lastModifiedDate"` + State string `json:"string"` + } `json:"attributes"` + Relationships struct { + Response struct { + Data struct { + Type string `json:"type"` + ID string `json:"id"` + } `json:"data"` + } `json:"response"` + } `json:"relationships"` + Links Links `json:"links"` +} + +type ReviewsListResponse struct { + Reviews []CustomerReview `json:"data"` + Responses []CustomerReviewResponse `json:"included"` + Links Links `json:"links"` + Meta Meta `json:"meta"` +} + +// GetReviews returns a list of reviews. +func (as *AppStore) GetReviews(appID string) (ReviewsListResponse, error) { + nilResponse := ReviewsListResponse{} + + if err := as.createToken(defaultJWTLife); err != nil { + return nilResponse, fmt.Errorf("failed to create token: %v", err) + } + + queryParams := url.Values{} + queryParams.Add("include", "response") + queryParams.Add("sort", "-createdDate") + //queryParams.Add("exists[publishedResponse]", "false") + url := fmt.Sprintf("https://api.appstoreconnect.apple.com/v1/apps/%s/customerReviews?%s", appID, queryParams.Encode()) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nilResponse, fmt.Errorf("failed to create http GET request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+as.token) + + client := &http.Client{ + Transport: &http.Transport{ + Proxy: download.GetProxy(as.Proxy), + TLSClientConfig: &tls.Config{InsecureSkipVerify: as.Insecure}, + }, + } + + resp, err := client.Do(req) + if err != nil { + return nilResponse, fmt.Errorf("failed to send http request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + var eresp ErrorResponse + if err := json.NewDecoder(resp.Body).Decode(&eresp); err != nil { + return nilResponse, fmt.Errorf("failed to JSON decode http response: %v", err) + } + var errOut string + for idx, e := range eresp.Errors { + errOut += fmt.Sprintf("%s%s: %s (%s)\n", strings.Repeat("\t", idx), e.Code, e.Title, e.Detail) + } + return nilResponse, fmt.Errorf("%s: %s", resp.Status, errOut) + } + + // For debugging, print the response body + // body, _ := io.ReadAll(resp.Body) + // return nil, fmt.Errorf("%s", body) + + var reviewsResponse ReviewsListResponse + if err := json.NewDecoder(resp.Body).Decode(&reviewsResponse); err != nil { + return nilResponse, fmt.Errorf("failed to JSON decode http response: %v", err) + } + + return reviewsResponse, nil +}