Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add as review ls #610

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*.so
*.dylib
*.a
/ipsw

# Test binary, build with `go test -c`
*.test
Expand Down
41 changes: 41 additions & 0 deletions cmd/ipsw/cmd/appstore/appstore_review.go
Original file line number Diff line number Diff line change
@@ -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()
},
}
158 changes: 158 additions & 0 deletions cmd/ipsw/cmd/appstore/appstore_review_ls.go
Original file line number Diff line number Diff line change
@@ -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
},
}
9 changes: 8 additions & 1 deletion pkg/appstore/appstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
118 changes: 118 additions & 0 deletions pkg/appstore/review.go
Original file line number Diff line number Diff line change
@@ -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
}