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  }