go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/internal/utils/utils.go (about) 1 // Copyright 2019 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package utils 16 17 import ( 18 "context" 19 "encoding/json" 20 "fmt" 21 "html/template" 22 "net/http" 23 "net/url" 24 "regexp" 25 "sort" 26 "strconv" 27 "strings" 28 29 "google.golang.org/grpc/codes" 30 "google.golang.org/grpc/status" 31 32 "go.chromium.org/luci/auth/identity" 33 buildbucketpb "go.chromium.org/luci/buildbucket/proto" 34 "go.chromium.org/luci/common/errors" 35 "go.chromium.org/luci/gae/service/datastore" 36 "go.chromium.org/luci/grpc/grpcutil" 37 "go.chromium.org/luci/server/auth" 38 ) 39 40 // MergeStrings merges multiple string slices together into a single slice, 41 // removing duplicates. 42 func MergeStrings(sss ...[]string) []string { 43 result := []string{} 44 seen := map[string]bool{} 45 for _, ss := range sss { 46 for _, s := range ss { 47 if seen[s] { 48 continue 49 } 50 seen[s] = true 51 result = append(result, s) 52 } 53 } 54 sort.Strings(result) 55 return result 56 } 57 58 // ObfuscateEmail converts a string containing email address email@address.com 59 // into email<junk>@address.com. 60 func ObfuscateEmail(email string) template.HTML { 61 email = template.HTMLEscapeString(email) 62 return template.HTML(strings.Replace( 63 email, "@", "<span style=\"display:none\">ohnoyoudont</span>@", -1)) 64 } 65 66 // ShortenEmail shortens Google emails. 67 func ShortenEmail(email string) string { 68 return strings.Replace(email, "@google.com", "", -1) 69 } 70 71 // TagGRPC annotates some gRPC with Milo specific semantics, specifically: 72 // * Marks the error as Unauthorized if the user is not logged in, 73 // and the underlying error was a 403 or 404. 74 // * Otherwise, tag the error with the original error code. 75 func TagGRPC(c context.Context, err error) error { 76 if err == nil { 77 return nil 78 } 79 loggedIn := auth.CurrentIdentity(c) != identity.AnonymousIdentity 80 code := grpcutil.Code(err) 81 if code == codes.NotFound || code == codes.PermissionDenied { 82 // Mask the errors, so they look the same. 83 if loggedIn { 84 return errors.Reason("not found").Tag(grpcutil.NotFoundTag).Err() 85 } 86 return errors.Reason("not logged in").Tag(grpcutil.UnauthenticatedTag).Err() 87 } 88 return status.Error(code, err.Error()) 89 } 90 91 // ParseIntFromForm parses an integer from a form. 92 func ParseIntFromForm(form url.Values, key string, base int, bitSize int) (int64, error) { 93 input, err := ReadExactOneFromForm(form, key) 94 if err != nil { 95 return 0, err 96 } 97 ret, err := strconv.ParseInt(input, 10, 64) 98 if err != nil { 99 return 0, errors.Annotate(err, "invalid %v; expected an integer; actual value: %v", key, input).Err() 100 } 101 return ret, nil 102 } 103 104 // ReadExactOneFromForm read a string from a form. 105 // There must be exactly one and non-empty entry of the given key in the form. 106 func ReadExactOneFromForm(form url.Values, key string) (string, error) { 107 input := form[key] 108 if len(input) != 1 || input[0] == "" { 109 return "", fmt.Errorf("multiple or missing %v; actual value: %v", key, input) 110 } 111 return input[0], nil 112 } 113 114 // BucketResourceID returns a string identifying the bucket resource. 115 // It is used when checking bucket permission. 116 func BucketResourceID(project, bucket string) string { 117 return fmt.Sprintf("luci.%s.%s", project, bucket) 118 } 119 120 // LegacyBuilderIDString returns a legacy string identifying the builder. 121 // It is used in the Milo datastore. 122 func LegacyBuilderIDString(bid *buildbucketpb.BuilderID) string { 123 return fmt.Sprintf("buildbucket/%s/%s", BucketResourceID(bid.Project, bid.Bucket), bid.Builder) 124 } 125 126 var ErrInvalidLegacyBuilderID = errors.New("the string is not a valid legacy builder ID (format: buildbucket/luci.<project>.<bucket>/<builder>)") 127 var legacyBuilderIDRe = regexp.MustCompile(`^buildbucket/luci\.([^./]+)\.([^/]+)/([^/]+)$`) 128 129 // ParseLegacyBuilderID parses the legacy builder ID 130 // (e.g. `buildbucket/luci.<project>.<bucket>/<builder>`) and returns the 131 // BuilderID struct. 132 func ParseLegacyBuilderID(bid string) (*buildbucketpb.BuilderID, error) { 133 match := legacyBuilderIDRe.FindStringSubmatch(bid) 134 if len(match) == 0 { 135 return nil, ErrInvalidLegacyBuilderID 136 } 137 return &buildbucketpb.BuilderID{ 138 Project: match[1], 139 Bucket: match[2], 140 Builder: match[3], 141 }, nil 142 } 143 144 var ErrInvalidBuilderID = errors.New("the string is not a valid builder ID") 145 var builderIDRe = regexp.MustCompile("^([^/]+)/([^/]+)/([^/]+)$") 146 147 // ParseBuilderID parses the canonical builder ID 148 // (e.g. `<project>/<bucket>/<builder>`) and returns the BuilderID struct. 149 func ParseBuilderID(bid string) (*buildbucketpb.BuilderID, error) { 150 match := builderIDRe.FindStringSubmatch(bid) 151 if len(match) == 0 { 152 return nil, ErrInvalidBuilderID 153 } 154 return &buildbucketpb.BuilderID{ 155 Project: match[1], 156 Bucket: match[2], 157 Builder: match[3], 158 }, nil 159 } 160 161 var buildbucketBuildIDRe = regexp.MustCompile(`^buildbucket/(\d+)$`) 162 var legacyBuildbucketBuildIDRe = regexp.MustCompile(`^buildbucket/luci\.([^./]+)\.([^/]+)/([^/]+)/(\d+)$`) 163 var ErrInvalidLegacyBuildID = errors.New("the string is not a valid legacy build ID") 164 165 // ParseBuildbucketBuildID parses the legacy build ID in the format of 166 // `buildbucket/<build_id>` 167 func ParseBuildbucketBuildID(bid string) (buildID int64, err error) { 168 match := buildbucketBuildIDRe.FindStringSubmatch(bid) 169 if len(match) == 0 { 170 return 0, ErrInvalidLegacyBuildID 171 } 172 buildID, err = strconv.ParseInt(match[1], 10, 64) 173 if err != nil { 174 return 0, ErrInvalidBuilderID 175 } 176 return buildID, nil 177 } 178 179 // ParseLegacyBuildbucketBuildID parses the legacy build ID in the format of 180 // `buildbucket/luci.<project>.<bucket>/<builder>/<number>`. 181 func ParseLegacyBuildbucketBuildID(bid string) (builderID *buildbucketpb.BuilderID, number int32, err error) { 182 match := legacyBuildbucketBuildIDRe.FindStringSubmatch(bid) 183 if len(match) == 0 { 184 return nil, 0, ErrInvalidLegacyBuildID 185 } 186 builderID = &buildbucketpb.BuilderID{ 187 Project: match[1], 188 Bucket: match[2], 189 Builder: match[3], 190 } 191 buildNum, err := strconv.ParseInt(match[4], 10, 32) 192 if err != nil { 193 return nil, 0, ErrInvalidLegacyBuildID 194 } 195 return builderID, int32(buildNum), nil 196 } 197 198 // GetJSONData fetches data from the given URL, parses the response body to `out`. 199 // It follows redirection and returns an error if the status code is 4xx or 5xx. 200 func GetJSONData(client *http.Client, url string, out any) (err error) { 201 response, err := client.Get(url) 202 if err != nil { 203 return err 204 } 205 defer func() { 206 err = errors.Flatten(errors.NewMultiError(response.Body.Close(), err)) 207 }() 208 209 if response.StatusCode >= 400 && response.StatusCode <= 599 { 210 return fmt.Errorf("failed to fetch data: %q returned code %q", url, response.Status) 211 } 212 213 dec := json.NewDecoder(response.Body) 214 return dec.Decode(out) 215 } 216 217 // ReplaceNSEWith takes an errors.MultiError returned by a datastore.Get() on a 218 // slice (which is always a MultiError), filters out all 219 // datastore.ErrNoSuchEntity or replaces it with replacement instances, and 220 // returns an error generated by errors.LazyMultiError. 221 func ReplaceNSEWith(err errors.MultiError, replacement error) error { 222 lme := errors.NewLazyMultiError(len(err)) 223 for i, ierr := range err { 224 if ierr == datastore.ErrNoSuchEntity { 225 ierr = replacement 226 } 227 lme.Assign(i, ierr) 228 } 229 return lme.Get() 230 }