From a3cd027b647a6a9cdf2dc95fa226f6684ecb6706 Mon Sep 17 00:00:00 2001 From: kenstir Date: Thu, 19 Dec 2024 21:25:19 -0500 Subject: [PATCH 1/9] feat: add `ipsw as review` cmd --- .gitignore | 1 + cmd/ipsw/cmd/appstore/appstore_review.go | 41 ++++++++ cmd/ipsw/cmd/appstore/appstore_review_ls.go | 107 ++++++++++++++++++++ pkg/appstore/review.go | 83 +++++++++++++++ 4 files changed, 232 insertions(+) create mode 100644 cmd/ipsw/cmd/appstore/appstore_review.go create mode 100644 cmd/ipsw/cmd/appstore/appstore_review_ls.go create mode 100644 pkg/appstore/review.go 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..f754d752aa --- /dev/null +++ b/cmd/ipsw/cmd/appstore/appstore_review_ls.go @@ -0,0 +1,107 @@ +/* +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 ( + "fmt" + + "github.com/apex/log" + "github.com/fatih/color" + "github.com/spf13/cobra" + // "github.com/blacktop/ipsw/internal/utils" + // "github.com/blacktop/ipsw/pkg/appstore" + + "github.com/spf13/viper" +) + +func init() { + ASReviewCmd.AddCommand(ASReviewListCmd) +} + +// 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") + + // parent flags + viper.BindPFlag("appstore.p8", cmd.Flags().Lookup("p8")) + viper.BindPFlag("appstore.iss", cmd.Flags().Lookup("iss")) + viper.BindPFlag("appstore.kid", cmd.Flags().Lookup("kid")) + viper.BindPFlag("appstore.jwt", cmd.Flags().Lookup("jwt")) + // 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") + } + + // as := appstore.NewAppStore( + // viper.GetString("appstore.p8"), + // viper.GetString("appstore.iss"), + // viper.GetString("appstore.kid"), + // viper.GetString("appstore.jwt"), + // ) + + // profs, err := as.GetReviews() + // if err != nil { + // return err + // } + + // log.Info("Provisioning Reviews:") + // for _, prof := range profs { + // if prof.IsExpired() || prof.IsInvalid() { + // utils.Indent(log.Error, 2)(fmt.Sprintf("%s: %s (%s), Expires: %s", prof.ID, prof.Attributes.Name, prof.Attributes.ReviewState, prof.Attributes.ExpirationDate.Format("02Jan2006 15:04:05"))) + // } else { + // utils.Indent(log.Info, 2)(fmt.Sprintf("%s: %s (%s), Expires: %s", prof.ID, prof.Attributes.Name, prof.Attributes.ReviewState, prof.Attributes.ExpirationDate.Format("02Jan2006 15:04:05"))) + // } + // certs, err := as.GetReviewCerts(prof.ID) + // if err != nil { + // return err + // } + // if len(certs) > 0 { + // utils.Indent(log.Info, 3)("Certificates:") + // } + // for _, cert := range certs { + // utils.Indent(log.Info, 4)(fmt.Sprintf("%s: %s (%s), Expires: %s", cert.ID, cert.Attributes.Name, cert.Attributes.CertificateType, cert.Attributes.ExpirationDate.Format("02Jan2006 15:04:05"))) + // } + // devs, err := as.GetReviewDevices(prof.ID) + // if err != nil { + // return err + // } + // if len(devs) > 0 { + // utils.Indent(log.Info, 3)("Devices:") + // } + // for _, dev := range devs { + // utils.Indent(log.Info, 4)(fmt.Sprintf("%s: %s (%s)", dev.ID, dev.Attributes.Name, dev.Attributes.DeviceClass)) + // } + // } + + return nil + }, +} diff --git a/pkg/appstore/review.go b/pkg/appstore/review.go new file mode 100644 index 0000000000..efc496c07e --- /dev/null +++ b/pkg/appstore/review.go @@ -0,0 +1,83 @@ +package appstore + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/blacktop/ipsw/internal/download" +) + +type Review 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 { + Links Links `json:"links"` + } `json:"response"` + } `json:"relationships"` + Links Links `json:"links"` +} + +type ReviewsResponse struct { + Data []Review `json:"data"` + Links Links `json:"links"` + Meta Meta `json:"meta"` +} + +// GetReviews returns a list of reviews. +func (as *AppStore) GetReviews(appID string) ([]Review, error) { + + if err := as.createToken(defaultJWTLife); err != nil { + return nil, fmt.Errorf("failed to create token: %v", err) + } + + url := fmt.Sprintf("https://api.appstoreconnect.apple.com/v1/apps/%s/customerReviews", appID) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, 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 nil, 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 nil, 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 nil, fmt.Errorf("%s: %s", resp.Status, errOut) + } + + var reviewsResponseList ReviewsResponse + if err := json.NewDecoder(resp.Body).Decode(&reviewsResponseList); err != nil { + return nil, fmt.Errorf("failed to JSON decode http response: %v", err) + } + + return reviewsResponseList.Data, nil +} From 9fb7ed12f79473a177e38dbeda838ec5973729c8 Mon Sep 17 00:00:00 2001 From: kenstir Date: Fri, 20 Dec 2024 11:17:52 -0500 Subject: [PATCH 2/9] Add `as review ls --id` flag --- cmd/ipsw/cmd/appstore/appstore_review_ls.go | 39 +++++++++++++-------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/cmd/ipsw/cmd/appstore/appstore_review_ls.go b/cmd/ipsw/cmd/appstore/appstore_review_ls.go index f754d752aa..ed4eaf16fd 100644 --- a/cmd/ipsw/cmd/appstore/appstore_review_ls.go +++ b/cmd/ipsw/cmd/appstore/appstore_review_ls.go @@ -27,14 +27,17 @@ import ( "github.com/apex/log" "github.com/fatih/color" "github.com/spf13/cobra" - // "github.com/blacktop/ipsw/internal/utils" - // "github.com/blacktop/ipsw/pkg/appstore" + "github.com/blacktop/ipsw/internal/utils" + "github.com/blacktop/ipsw/pkg/appstore" "github.com/spf13/viper" ) func init() { ASReviewCmd.AddCommand(ASReviewListCmd) + + ASReviewListCmd.Flags().String("id", "", "App ID") + viper.BindPFlag("appstore.review.ls.id", ASReviewListCmd.Flags().Lookup("id")) } // ASReviewListCmd represents the appstore review ls command @@ -56,24 +59,32 @@ var ASReviewListCmd = &cobra.Command{ viper.BindPFlag("appstore.iss", cmd.Flags().Lookup("iss")) viper.BindPFlag("appstore.kid", cmd.Flags().Lookup("kid")) viper.BindPFlag("appstore.jwt", cmd.Flags().Lookup("jwt")) + // flags + id := viper.GetString("appstore.review.ls.id") // 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") + return fmt.Errorf("you must provide (--p8, --iss and --kid) OR --jwt") + } + if id == "" { + return fmt.Errorf("you must provide --id") } - // as := appstore.NewAppStore( - // viper.GetString("appstore.p8"), - // viper.GetString("appstore.iss"), - // viper.GetString("appstore.kid"), - // viper.GetString("appstore.jwt"), - // ) + as := appstore.NewAppStore( + viper.GetString("appstore.p8"), + viper.GetString("appstore.iss"), + viper.GetString("appstore.kid"), + viper.GetString("appstore.jwt"), + ) - // profs, err := as.GetReviews() - // if err != nil { - // return err - // } + reviews, err := as.GetReviews(id) + if err != nil { + return err + } - // log.Info("Provisioning Reviews:") + log.Info("Reviews:") + for _, review := range reviews { + utils.Indent(log.Info, 2)(fmt.Sprintf("%s: %s", review.ID, review.Type)) + } // for _, prof := range profs { // if prof.IsExpired() || prof.IsInvalid() { // utils.Indent(log.Error, 2)(fmt.Sprintf("%s: %s (%s), Expires: %s", prof.ID, prof.Attributes.Name, prof.Attributes.ReviewState, prof.Attributes.ExpirationDate.Format("02Jan2006 15:04:05"))) From 94ebe3b9e052b8df36519ef6827160d5a6272d77 Mon Sep 17 00:00:00 2001 From: kenstir Date: Fri, 20 Dec 2024 16:37:37 -0500 Subject: [PATCH 3/9] fix: Handle Date with TZ as in `as review ls` --- pkg/appstore/appstore.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/appstore/appstore.go b/pkg/appstore/appstore.go index 57fb1ee266..42f16b3d12 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 From f0b613b86611be83797a4f284dcd6a8f6a9cd79d Mon Sep 17 00:00:00 2001 From: kenstir Date: Fri, 20 Dec 2024 18:23:57 -0500 Subject: [PATCH 4/9] feat: Minimally functional `as review ls` command * Sort reviews by date descending * Print using fmt.Printf not log.Info, so it is readable in the terminal --- cmd/ipsw/cmd/appstore/appstore_review_ls.go | 48 ++++++++------------- pkg/appstore/appstore.go | 3 ++ 2 files changed, 20 insertions(+), 31 deletions(-) diff --git a/cmd/ipsw/cmd/appstore/appstore_review_ls.go b/cmd/ipsw/cmd/appstore/appstore_review_ls.go index ed4eaf16fd..ce102e8476 100644 --- a/cmd/ipsw/cmd/appstore/appstore_review_ls.go +++ b/cmd/ipsw/cmd/appstore/appstore_review_ls.go @@ -23,12 +23,13 @@ package appstore import ( "fmt" + "sort" + "strings" "github.com/apex/log" + "github.com/blacktop/ipsw/pkg/appstore" "github.com/fatih/color" "github.com/spf13/cobra" - "github.com/blacktop/ipsw/internal/utils" - "github.com/blacktop/ipsw/pkg/appstore" "github.com/spf13/viper" ) @@ -81,37 +82,22 @@ var ASReviewListCmd = &cobra.Command{ return err } - log.Info("Reviews:") + // sort reviews by Created descending + sort.Slice(reviews, func(i, j int) bool { + return reviews[j].Attributes.Created.Before(reviews[i].Attributes.Created) + }) + + fmt.Printf("Reviews\n") for _, review := range reviews { - utils.Indent(log.Info, 2)(fmt.Sprintf("%s: %s", review.ID, review.Type)) + date := review.Attributes.Created.Format("Jan _2 2006") + stars := strings.Repeat("★", review.Attributes.Rating) + hrule := strings.Repeat("-", 16) + fmt.Printf("\n%s\n%s [%-5s] by %s\n", hrule, date, stars, review.Attributes.Reviewer) + fmt.Printf("%s\n\n", review.Attributes.Title) + fmt.Printf(" %s\n", review.Attributes.Body) } - // for _, prof := range profs { - // if prof.IsExpired() || prof.IsInvalid() { - // utils.Indent(log.Error, 2)(fmt.Sprintf("%s: %s (%s), Expires: %s", prof.ID, prof.Attributes.Name, prof.Attributes.ReviewState, prof.Attributes.ExpirationDate.Format("02Jan2006 15:04:05"))) - // } else { - // utils.Indent(log.Info, 2)(fmt.Sprintf("%s: %s (%s), Expires: %s", prof.ID, prof.Attributes.Name, prof.Attributes.ReviewState, prof.Attributes.ExpirationDate.Format("02Jan2006 15:04:05"))) - // } - // certs, err := as.GetReviewCerts(prof.ID) - // if err != nil { - // return err - // } - // if len(certs) > 0 { - // utils.Indent(log.Info, 3)("Certificates:") - // } - // for _, cert := range certs { - // utils.Indent(log.Info, 4)(fmt.Sprintf("%s: %s (%s), Expires: %s", cert.ID, cert.Attributes.Name, cert.Attributes.CertificateType, cert.Attributes.ExpirationDate.Format("02Jan2006 15:04:05"))) - // } - // devs, err := as.GetReviewDevices(prof.ID) - // if err != nil { - // return err - // } - // if len(devs) > 0 { - // utils.Indent(log.Info, 3)("Devices:") - // } - // for _, dev := range devs { - // utils.Indent(log.Info, 4)(fmt.Sprintf("%s: %s (%s)", dev.ID, dev.Attributes.Name, dev.Attributes.DeviceClass)) - // } - // } + ratingsUrl := fmt.Sprintf("https://appstoreconnect.apple.com/apps/%s/distribution/activity/ios/ratingsResponses", id) + fmt.Printf("\nTo respond, visit %s", ratingsUrl) return nil }, diff --git a/pkg/appstore/appstore.go b/pkg/appstore/appstore.go index 42f16b3d12..07eb814212 100644 --- a/pkg/appstore/appstore.go +++ b/pkg/appstore/appstore.go @@ -79,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 From 59e09a85157e79e0af8ca120efc7bb89a0487918 Mon Sep 17 00:00:00 2001 From: kenstir Date: Sat, 21 Dec 2024 18:14:43 -0500 Subject: [PATCH 5/9] Include customerServiceResponses in GetReviews and print (responded) if we responded. --- cmd/ipsw/cmd/appstore/appstore_review_ls.go | 29 ++++++-- pkg/appstore/review.go | 77 +++++++++++++++------ 2 files changed, 78 insertions(+), 28 deletions(-) diff --git a/cmd/ipsw/cmd/appstore/appstore_review_ls.go b/cmd/ipsw/cmd/appstore/appstore_review_ls.go index ce102e8476..31f3185104 100644 --- a/cmd/ipsw/cmd/appstore/appstore_review_ls.go +++ b/cmd/ipsw/cmd/appstore/appstore_review_ls.go @@ -64,10 +64,10 @@ var ASReviewListCmd = &cobra.Command{ id := viper.GetString("appstore.review.ls.id") // 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") + return fmt.Errorf("you must provide (--p8, --iss and --kid) OR --jwt") } if id == "" { - return fmt.Errorf("you must provide --id") + return fmt.Errorf("you must provide --id") } as := appstore.NewAppStore( @@ -77,24 +77,41 @@ var ASReviewListCmd = &cobra.Command{ viper.GetString("appstore.jwt"), ) - reviews, err := as.GetReviews(id) + 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 + } + // sort reviews by Created descending + reviews := reviewsResponse.Reviews sort.Slice(reviews, func(i, j int) bool { return reviews[j].Attributes.Created.Before(reviews[i].Attributes.Created) }) + // display reviews in a format useful for customer service fmt.Printf("Reviews\n") - for _, review := range reviews { + fmt.Printf("%d reviews\n", len(reviews)) + fmt.Printf("%d responses\n", len(responsesById)) + for idx, review := range reviews { date := review.Attributes.Created.Format("Jan _2 2006") stars := strings.Repeat("★", review.Attributes.Rating) hrule := strings.Repeat("-", 16) - fmt.Printf("\n%s\n%s [%-5s] by %s\n", hrule, date, stars, review.Attributes.Reviewer) + fmt.Printf("\n%s\n[%3d]\n%s [%-5s] by %s\n", hrule, idx, date, stars, review.Attributes.Reviewer) fmt.Printf("%s\n\n", review.Attributes.Title) - fmt.Printf(" %s\n", review.Attributes.Body) + + responseData := review.Relationships.Response.Data + if responseData != nil { + fmt.Printf(" (responded)\n") + // fmt.Printf(" response: %v\n", responseId) + } else { + fmt.Printf(" %s\n", review.Attributes.Body) + } } ratingsUrl := fmt.Sprintf("https://appstoreconnect.apple.com/apps/%s/distribution/activity/ios/ratingsResponses", id) fmt.Printf("\nTo respond, visit %s", ratingsUrl) diff --git a/pkg/appstore/review.go b/pkg/appstore/review.go index efc496c07e..8841232ad2 100644 --- a/pkg/appstore/review.go +++ b/pkg/appstore/review.go @@ -4,48 +4,77 @@ import ( "crypto/tls" "encoding/json" "fmt" + //"io" "net/http" + "net/url" "strings" "github.com/blacktop/ipsw/internal/download" ) -type Review struct { +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"` + 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 ReviewsResponse struct { - Data []Review `json:"data"` - Links Links `json:"links"` - Meta Meta `json:"meta"` +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) ([]Review, error) { +func (as *AppStore) GetReviews(appID string) (ReviewsListResponse, error) { + nilResponse := ReviewsListResponse{} if err := as.createToken(defaultJWTLife); err != nil { - return nil, fmt.Errorf("failed to create token: %v", err) + return nilResponse, fmt.Errorf("failed to create token: %v", err) } - url := fmt.Sprintf("https://api.appstoreconnect.apple.com/v1/apps/%s/customerReviews", appID) + queryParams := url.Values{} + queryParams.Add("include", "response") + 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 nil, fmt.Errorf("failed to create http GET request: %v", err) + return nilResponse, fmt.Errorf("failed to create http GET request: %v", err) } req.Header.Set("Authorization", "Bearer "+as.token) @@ -58,26 +87,30 @@ func (as *AppStore) GetReviews(appID string) ([]Review, error) { resp, err := client.Do(req) if err != nil { - return nil, fmt.Errorf("failed to send http request: %v", err) + 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 nil, fmt.Errorf("failed to JSON decode http response: %v", err) + 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 nil, fmt.Errorf("%s: %s", resp.Status, errOut) + return nilResponse, fmt.Errorf("%s: %s", resp.Status, errOut) } - var reviewsResponseList ReviewsResponse - if err := json.NewDecoder(resp.Body).Decode(&reviewsResponseList); err != nil { - return nil, fmt.Errorf("failed to JSON decode http response: %v", err) + // 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 reviewsResponseList.Data, nil + return reviewsResponse, nil } From 538306cc6c7be9842542c780875c213321355d0c Mon Sep 17 00:00:00 2001 From: kenstir Date: Sun, 22 Dec 2024 12:54:49 -0500 Subject: [PATCH 6/9] feat: Add `appstore review ls` command * Add `--after` to limit reviews by date * Use sort=-createdDate so we don't have to sort * Use include=response and print body of review if we haven't responded --- cmd/ipsw/cmd/appstore/appstore_review_ls.go | 56 ++++++++++++++------- pkg/appstore/review.go | 4 +- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/cmd/ipsw/cmd/appstore/appstore_review_ls.go b/cmd/ipsw/cmd/appstore/appstore_review_ls.go index 31f3185104..24cb31e45d 100644 --- a/cmd/ipsw/cmd/appstore/appstore_review_ls.go +++ b/cmd/ipsw/cmd/appstore/appstore_review_ls.go @@ -1,5 +1,5 @@ /* -Copyright © 2024 blacktop +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 @@ -23,8 +23,8 @@ package appstore import ( "fmt" - "sort" "strings" + "time" "github.com/apex/log" "github.com/blacktop/ipsw/pkg/appstore" @@ -38,7 +38,9 @@ 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") viper.BindPFlag("appstore.review.ls.id", ASReviewListCmd.Flags().Lookup("id")) + viper.BindPFlag("appstore.review.ls.after", ASReviewListCmd.Flags().Lookup("after")) } // ASReviewListCmd represents the appstore review ls command @@ -62,6 +64,7 @@ var ASReviewListCmd = &cobra.Command{ viper.BindPFlag("appstore.jwt", cmd.Flags().Lookup("jwt")) // flags id := viper.GetString("appstore.review.ls.id") + after := viper.GetString("appstore.review.ls.after") // 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") @@ -69,7 +72,11 @@ var ASReviewListCmd = &cobra.Command{ if id == "" { return fmt.Errorf("you must provide --id") } - + afterDate, err := time.Parse("2006-01-02", after) + if after != "" && err != nil { + return err + } + as := appstore.NewAppStore( viper.GetString("appstore.p8"), viper.GetString("appstore.iss"), @@ -88,31 +95,44 @@ var ASReviewListCmd = &cobra.Command{ responsesById[response.ID] = response } - // sort reviews by Created descending - reviews := reviewsResponse.Reviews - sort.Slice(reviews, func(i, j int) bool { - return reviews[j].Attributes.Created.Before(reviews[i].Attributes.Created) - }) - // display reviews in a format useful for customer service - fmt.Printf("Reviews\n") - fmt.Printf("%d reviews\n", len(reviews)) - fmt.Printf("%d responses\n", len(responsesById)) - for idx, review := range reviews { + reviewCount := 0 + responseCount := 0 + for _, review := range reviewsResponse.Reviews { + if time.Time(review.Attributes.Created).Before(afterDate) { + break + } + + // print summary + reviewCount += 1 date := review.Attributes.Created.Format("Jan _2 2006") stars := strings.Repeat("★", review.Attributes.Rating) - hrule := strings.Repeat("-", 16) - fmt.Printf("\n%s\n[%3d]\n%s [%-5s] by %s\n", hrule, idx, date, stars, review.Attributes.Reviewer) - fmt.Printf("%s\n\n", review.Attributes.Title) + 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 { - fmt.Printf(" (responded)\n") - // fmt.Printf(" response: %v\n", responseId) + 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 after != "" { + fmt.Printf("\n%d reviews since %s\n", reviewCount, after) + } 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) diff --git a/pkg/appstore/review.go b/pkg/appstore/review.go index 8841232ad2..303dcbb7e1 100644 --- a/pkg/appstore/review.go +++ b/pkg/appstore/review.go @@ -4,7 +4,7 @@ import ( "crypto/tls" "encoding/json" "fmt" - //"io" + "net/http" "net/url" "strings" @@ -71,6 +71,8 @@ func (as *AppStore) GetReviews(appID string) (ReviewsListResponse, error) { 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 { From 86a92918c523d0021db7e9a6c8dbfaf13be7a2f4 Mon Sep 17 00:00:00 2001 From: kenstir Date: Mon, 23 Dec 2024 17:28:27 -0500 Subject: [PATCH 7/9] feat: Add `appstore review ls` command * Add `--since` to limit reviews by duration * Exit 2 if no reviews to aid scripting --- cmd/ipsw/cmd/appstore/appstore_review_ls.go | 26 ++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/cmd/ipsw/cmd/appstore/appstore_review_ls.go b/cmd/ipsw/cmd/appstore/appstore_review_ls.go index 24cb31e45d..ae8ea0b7ca 100644 --- a/cmd/ipsw/cmd/appstore/appstore_review_ls.go +++ b/cmd/ipsw/cmd/appstore/appstore_review_ls.go @@ -23,6 +23,7 @@ package appstore import ( "fmt" + "os" "strings" "time" @@ -38,9 +39,11 @@ 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("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 @@ -65,6 +68,7 @@ var ASReviewListCmd = &cobra.Command{ // 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") @@ -72,10 +76,21 @@ var ASReviewListCmd = &cobra.Command{ 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"), @@ -127,8 +142,8 @@ var ASReviewListCmd = &cobra.Command{ } // print summary - if after != "" { - fmt.Printf("\n%d reviews since %s\n", reviewCount, after) + 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) } @@ -136,6 +151,11 @@ var ASReviewListCmd = &cobra.Command{ 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 }, } From dde519766d7f752836b74d8db29b7ca15be71e33 Mon Sep 17 00:00:00 2001 From: kenstir Date: Sun, 19 Jan 2025 20:27:17 -0500 Subject: [PATCH 8/9] as review ls: Only print summary if there are reviews or --verbose --- cmd/ipsw/cmd/appstore/appstore_review_ls.go | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/cmd/ipsw/cmd/appstore/appstore_review_ls.go b/cmd/ipsw/cmd/appstore/appstore_review_ls.go index ae8ea0b7ca..4e393c7534 100644 --- a/cmd/ipsw/cmd/appstore/appstore_review_ls.go +++ b/cmd/ipsw/cmd/appstore/appstore_review_ls.go @@ -118,7 +118,7 @@ var ASReviewListCmd = &cobra.Command{ break } - // print summary + // print review summary reviewCount += 1 date := review.Attributes.Created.Format("Jan _2 2006") stars := strings.Repeat("★", review.Attributes.Rating) @@ -141,15 +141,17 @@ var ASReviewListCmd = &cobra.Command{ } } - // print summary - 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) + // 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) } - 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 { From b3650a833a49815f239b69e7fb2408a272f57158 Mon Sep 17 00:00:00 2001 From: kenstir Date: Thu, 23 Jan 2025 18:48:40 -0500 Subject: [PATCH 9/9] Remove duplicate BindPFlag calls --- cmd/ipsw/cmd/appstore/appstore_review_ls.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cmd/ipsw/cmd/appstore/appstore_review_ls.go b/cmd/ipsw/cmd/appstore/appstore_review_ls.go index 4e393c7534..df079b5c61 100644 --- a/cmd/ipsw/cmd/appstore/appstore_review_ls.go +++ b/cmd/ipsw/cmd/appstore/appstore_review_ls.go @@ -60,11 +60,6 @@ var ASReviewListCmd = &cobra.Command{ } color.NoColor = viper.GetBool("no-color") - // parent flags - viper.BindPFlag("appstore.p8", cmd.Flags().Lookup("p8")) - viper.BindPFlag("appstore.iss", cmd.Flags().Lookup("iss")) - viper.BindPFlag("appstore.kid", cmd.Flags().Lookup("kid")) - viper.BindPFlag("appstore.jwt", cmd.Flags().Lookup("jwt")) // flags id := viper.GetString("appstore.review.ls.id") after := viper.GetString("appstore.review.ls.after")