go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/frontend/middleware.go (about)

     1  // Copyright 2017 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 frontend
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"fmt"
    21  	"html/template"
    22  	"net/http"
    23  	"net/url"
    24  	"os"
    25  	"regexp"
    26  	"strconv"
    27  	"strings"
    28  	"time"
    29  	"unicode/utf8"
    30  
    31  	"github.com/golang/protobuf/ptypes"
    32  	"github.com/russross/blackfriday/v2"
    33  	"go.opentelemetry.io/otel/trace"
    34  	"google.golang.org/protobuf/types/known/timestamppb"
    35  
    36  	"go.chromium.org/luci/auth/identity"
    37  	buildbucketpb "go.chromium.org/luci/buildbucket/proto"
    38  	"go.chromium.org/luci/buildbucket/protoutil"
    39  	"go.chromium.org/luci/common/clock"
    40  	"go.chromium.org/luci/common/data/text/sanitizehtml"
    41  	"go.chromium.org/luci/common/errors"
    42  	"go.chromium.org/luci/common/logging"
    43  	"go.chromium.org/luci/grpc/grpcutil"
    44  	"go.chromium.org/luci/logdog/common/types"
    45  	"go.chromium.org/luci/server/auth"
    46  	"go.chromium.org/luci/server/gtm"
    47  	"go.chromium.org/luci/server/router"
    48  	"go.chromium.org/luci/server/templates"
    49  
    50  	"go.chromium.org/luci/milo/frontend/ui"
    51  	"go.chromium.org/luci/milo/internal/buildsource/buildbucket"
    52  	"go.chromium.org/luci/milo/internal/config"
    53  	"go.chromium.org/luci/milo/internal/git"
    54  	"go.chromium.org/luci/milo/internal/git/gitacls"
    55  	"go.chromium.org/luci/milo/internal/projectconfig"
    56  	"go.chromium.org/luci/milo/internal/utils"
    57  )
    58  
    59  // A collection of useful templating functions
    60  
    61  // funcMap is what gets fed into the template bundle.
    62  var funcMap = template.FuncMap{
    63  	"botLink":          botLink,
    64  	"logdogLink":       logdogLink,
    65  	"duration":         utils.Duration,
    66  	"faviconMIMEType":  faviconMIMEType,
    67  	"formatCommitDesc": formatCommitDesc,
    68  	"formatTime":       formatTime,
    69  	"humanDuration":    utils.HumanDuration,
    70  	"localTime":        localTime,
    71  	"localTimestamp":   localTimestamp,
    72  	"localTimeTooltip": localTimeTooltip,
    73  	"obfuscateEmail":   utils.ObfuscateEmail,
    74  	"pagedURL":         pagedURL,
    75  	"parseRFC3339":     parseRFC3339,
    76  	"percent":          percent,
    77  	"prefix":           prefix,
    78  	"renderMarkdown":   renderMarkdown,
    79  	"sanitizeHTML":     sanitizeHTML,
    80  	"shortenEmail":     utils.ShortenEmail,
    81  	"startswith":       strings.HasPrefix,
    82  	"sub":              sub,
    83  	"toLower":          strings.ToLower,
    84  	"toTime":           toTime,
    85  	"join":             strings.Join,
    86  	"trimLong":         trimLongString,
    87  	"gitilesCommitURL": protoutil.GitilesCommitURL,
    88  	"urlPathEscape":    url.PathEscape,
    89  }
    90  
    91  // trimLongString returns a potentially shortened string with "…" suffix.
    92  // If maxRuneCount < 1, panics.
    93  func trimLongString(maxRuneCount int, s string) string {
    94  	if maxRuneCount < 1 {
    95  		panic("maxRunCount must be >= 1")
    96  	}
    97  
    98  	if utf8.RuneCountInString(s) <= maxRuneCount {
    99  		return s
   100  	}
   101  
   102  	// Take first maxRuneCount-1 runes.
   103  	count := 0
   104  	for i := range s {
   105  		count++
   106  		if count == maxRuneCount {
   107  			return s[:i] + "…"
   108  		}
   109  	}
   110  	panic("unreachable")
   111  }
   112  
   113  // localTime returns a <span> element with t in human format
   114  // that will be converted to local timezone in the browser.
   115  // Recommended usage: {{ .Date | localTime "N/A" }}
   116  func localTime(ifZero string, t time.Time) template.HTML {
   117  	return localTimeCommon(ifZero, t, "", t.Format(time.RFC850))
   118  }
   119  
   120  // localTimestamp is like localTime, but accepts a Timestamp protobuf.
   121  func localTimestamp(ifZero string, ts *timestamppb.Timestamp) template.HTML {
   122  	if ts == nil {
   123  		return template.HTML(template.HTMLEscapeString(ifZero))
   124  	}
   125  	t := ts.AsTime()
   126  	return localTime(ifZero, t)
   127  }
   128  
   129  // localTimeTooltip is similar to localTime, but shows time in a tooltip and
   130  // allows to specify inner text to be added to the created <span> element.
   131  // Recommended usage: {{ .Date | localTimeTooltip "innerText" "N/A" }}
   132  func localTimeTooltip(innerText string, ifZero string, t time.Time) template.HTML {
   133  	return localTimeCommon(ifZero, t, "tooltip-only", innerText)
   134  }
   135  
   136  func localTimeCommon(ifZero string, t time.Time, tooltipClass string, innerText string) template.HTML {
   137  	if t.IsZero() {
   138  		return template.HTML(template.HTMLEscapeString(ifZero))
   139  	}
   140  	milliseconds := t.UnixNano() / 1e6
   141  	return template.HTML(fmt.Sprintf(
   142  		`<span class="local-time %s" data-timestamp="%d">%s</span>`,
   143  		tooltipClass,
   144  		milliseconds,
   145  		template.HTMLEscapeString(innerText)))
   146  }
   147  
   148  // logdogLink generates a link with URL pointing to logdog host and a label
   149  // corresponding to the log name. In case `raw` is true, the link points to the
   150  // raw version of the log (without UI) and the label becomes "raw".
   151  func logdogLink(log buildbucketpb.Log, raw bool) template.HTML {
   152  	rawURL := "#invalid-logdog-link"
   153  	if sa, err := types.ParseURL(log.Url); err == nil {
   154  		u := url.URL{
   155  			Scheme: "https",
   156  			Host:   sa.Host,
   157  			Path:   fmt.Sprintf("/logs/%s/%s", sa.Project, sa.Path),
   158  		}
   159  		if raw {
   160  			u.RawQuery = "format=raw"
   161  		}
   162  		rawURL = u.String()
   163  	}
   164  	name := log.Name
   165  	if raw {
   166  		name = "raw"
   167  	}
   168  	return ui.NewLink(name, rawURL, fmt.Sprintf("raw log %s", log.Name)).HTML()
   169  }
   170  
   171  // rURL matches anything that looks like an https:// URL.
   172  var rURL = regexp.MustCompile(`\bhttps://\S*\b`)
   173  
   174  // rBUGLINE matches a bug line in a commit, including if it is quoted.
   175  // Expected formats: "BUG: 1234,1234", "bugs=1234", "  >  >  BUG: 123"
   176  var rBUGLINE = regexp.MustCompile(`(?m)^(>| )*(?i:bugs?)[:=].+$`)
   177  
   178  // rBUG matches expected items in a bug line.  Expected format: 12345, project:12345, #12345
   179  var rBUG = regexp.MustCompile(`\b(\w+:)?#?\d+\b`)
   180  
   181  // Expected formats: b/123456, crbug/123456, crbug/project/123456, crbug:123456, etc.
   182  var rBUGLINK = regexp.MustCompile(`\b(b|crbug(\.com)?([:/]\w+)?)[:/]\d+\b`)
   183  
   184  // tURL is a URL template.
   185  var tURL = template.Must(template.New("tURL").Parse("<a href=\"{{.URL}}\">{{.Label}}</a>"))
   186  
   187  // formatChunk is either an already-processed trusted template.HTML, produced
   188  // by some previous regexp, or an untrusted string that still needs escaping or
   189  // further processing by regexps. We keep track of the distinction to avoid
   190  // escaping twice and rewrite rules applying inside HTML tags.
   191  //
   192  // At most one of str or html will be non-empty. That one is the field in use.
   193  type formatChunk struct {
   194  	str  string
   195  	html template.HTML
   196  }
   197  
   198  // replaceAllInChunks behaves like Regexp.ReplaceAllStringFunc, but it only
   199  // acts on unprocessed elements of chunks. Already-processed elements are left
   200  // as-is. repl returns trusted HTML, performing any necessary escaping.
   201  func replaceAllInChunks(chunks []formatChunk, re *regexp.Regexp, repl func(string) template.HTML) []formatChunk {
   202  	var ret []formatChunk
   203  	for _, chunk := range chunks {
   204  		if len(chunk.html) != 0 {
   205  			ret = append(ret, chunk)
   206  			continue
   207  		}
   208  		s := chunk.str
   209  		for len(s) != 0 {
   210  			loc := re.FindStringIndex(s)
   211  			if loc == nil {
   212  				ret = append(ret, formatChunk{str: s})
   213  				break
   214  			}
   215  			if loc[0] > 0 {
   216  				ret = append(ret, formatChunk{str: s[:loc[0]]})
   217  			}
   218  			html := repl(s[loc[0]:loc[1]])
   219  			ret = append(ret, formatChunk{html: html})
   220  			s = s[loc[1]:]
   221  		}
   222  	}
   223  	return ret
   224  }
   225  
   226  // chunksToHTML concatenates chunks together, escaping as needed, to return a
   227  // final completed HTML string.
   228  func chunksToHTML(chunks []formatChunk) template.HTML {
   229  	buf := bytes.Buffer{}
   230  	for _, chunk := range chunks {
   231  		if len(chunk.html) != 0 {
   232  			buf.WriteString(string(chunk.html))
   233  		} else {
   234  			buf.WriteString(template.HTMLEscapeString(chunk.str))
   235  		}
   236  	}
   237  	return template.HTML(buf.String())
   238  }
   239  
   240  type link struct {
   241  	Label string
   242  	URL   string
   243  }
   244  
   245  func makeLink(label, href string) template.HTML {
   246  	buf := bytes.Buffer{}
   247  	if err := tURL.Execute(&buf, link{label, href}); err != nil {
   248  		return template.HTML(template.HTMLEscapeString(label))
   249  	}
   250  	return template.HTML(buf.String())
   251  }
   252  
   253  func replaceLinkChunks(chunks []formatChunk) []formatChunk {
   254  	// Replace https:// URLs
   255  	chunks = replaceAllInChunks(chunks, rURL, func(s string) template.HTML {
   256  		return makeLink(s, s)
   257  	})
   258  	// Replace b/ and crbug/ URLs
   259  	chunks = replaceAllInChunks(chunks, rBUGLINK, func(s string) template.HTML {
   260  		// Normalize separator.
   261  		u := strings.Replace(s, ":", "/", -1)
   262  		u = strings.Replace(u, "crbug/", "crbug.com/", 1)
   263  		scheme := "https://"
   264  		if strings.HasPrefix(u, "b/") {
   265  			scheme = "http://"
   266  		}
   267  		return makeLink(s, scheme+u)
   268  	})
   269  	return chunks
   270  }
   271  
   272  // botLink generates a link to a swarming bot given a buildbucketpb.BuildInfra_Swarming struct.
   273  func botLink(s *buildbucketpb.BuildInfra_Swarming) (result template.HTML) {
   274  	for _, d := range s.GetBotDimensions() {
   275  		if d.Key == "id" {
   276  			return ui.NewLink(
   277  				d.Value,
   278  				fmt.Sprintf("https://%s/bot?id=%s", s.Hostname, d.Value),
   279  				fmt.Sprintf("swarming bot %s", d.Value)).HTML()
   280  		}
   281  	}
   282  	return "N/A"
   283  }
   284  
   285  // formatCommitDesc takes a commit message and adds embellishments such as:
   286  // * Linkify https:// URLs
   287  // * Linkify bug numbers using https://crbug.com/
   288  // * Linkify b/ bug links
   289  // * Linkify crbug/ bug links
   290  func formatCommitDesc(desc string) template.HTML {
   291  	chunks := []formatChunk{{str: desc}}
   292  	// Replace BUG: lines with URLs by rewriting all bug numbers with
   293  	// links. Run this first so later rules do not interfere with it. This
   294  	// allows a line like the following to work:
   295  	//
   296  	// Bug: https://crbug.com/1234, 5678
   297  	chunks = replaceAllInChunks(chunks, rBUGLINE, func(s string) template.HTML {
   298  		sChunks := []formatChunk{{str: s}}
   299  		// The call later in the parent function will not reach into
   300  		// sChunks, so run it separately.
   301  		sChunks = replaceLinkChunks(sChunks)
   302  		sChunks = replaceAllInChunks(sChunks, rBUG, func(sBug string) template.HTML {
   303  			path := strings.Replace(strings.Replace(sBug, "#", "", 1), ":", "/", 1)
   304  			return makeLink(sBug, "https://crbug.com/"+path)
   305  		})
   306  		return chunksToHTML(sChunks)
   307  	})
   308  	chunks = replaceLinkChunks(chunks)
   309  	return chunksToHTML(chunks)
   310  }
   311  
   312  // toTime returns the time.Time format for the proto timestamp.
   313  // If the proto timestamp is invalid, we return a zero-ed out time.Time.
   314  func toTime(ts *timestamppb.Timestamp) (result time.Time) {
   315  	// We want a zero-ed out time.Time, not one set to the epoch.
   316  	if t, err := ptypes.Timestamp(ts); err == nil {
   317  		result = t
   318  	}
   319  	return
   320  }
   321  
   322  // parseRFC3339 parses time represented as a RFC3339 or RFC3339Nano string.
   323  // If cannot parse, returns zero time.
   324  func parseRFC3339(s string) time.Time {
   325  	t, err := time.Parse(time.RFC3339, s)
   326  	if err == nil {
   327  		return t
   328  	}
   329  	t, err = time.Parse(time.RFC3339Nano, s)
   330  	if err == nil {
   331  		return t
   332  	}
   333  	return time.Time{}
   334  }
   335  
   336  // formatTime takes a time object and returns a formatted RFC3339 string.
   337  func formatTime(t time.Time) string {
   338  	return t.Format(time.RFC3339)
   339  }
   340  
   341  // sub subtracts one number from another, because apparently go templates aren't
   342  // smart enough to do that.
   343  func sub(a, b int) int {
   344  	return a - b
   345  }
   346  
   347  // prefix abbriviates a string into specified number of characters.
   348  // Recommended usage: {{ .GitHash | prefix 8 }}
   349  func prefix(prefixLen int, s string) string {
   350  	if len(s) > prefixLen {
   351  		return s[:prefixLen]
   352  	}
   353  	return s
   354  }
   355  
   356  // GetLimit extracts the "limit", "numbuilds", or "num_builds" http param from
   357  // the request, or returns def implying no limit was specified.
   358  func GetLimit(r *http.Request, def int) int {
   359  	sLimit := r.FormValue("limit")
   360  	if sLimit == "" {
   361  		sLimit = r.FormValue("numbuilds")
   362  		if sLimit == "" {
   363  			sLimit = r.FormValue("num_builds")
   364  			if sLimit == "" {
   365  				return def
   366  			}
   367  		}
   368  	}
   369  	limit, err := strconv.Atoi(sLimit)
   370  	if err != nil || limit < 0 {
   371  		return def
   372  	}
   373  	return limit
   374  }
   375  
   376  // GetReload extracts the "reload" http param from the request,
   377  // or returns def implying no limit was specified.
   378  func GetReload(r *http.Request, def int) int {
   379  	sReload := r.FormValue("reload")
   380  	if sReload == "" {
   381  		return def
   382  	}
   383  	refresh, err := strconv.Atoi(sReload)
   384  	if err != nil || refresh < 0 {
   385  		return def
   386  	}
   387  	return refresh
   388  }
   389  
   390  // renderMarkdown renders the given text as markdown HTML.
   391  // This uses blackfriday to convert from markdown to HTML,
   392  // and sanitizehtml to allow only a small subset of HTML through.
   393  func renderMarkdown(t string) (results template.HTML) {
   394  	// We don't want auto punctuation, which changes "foo" into “foo”
   395  	r := blackfriday.NewHTMLRenderer(blackfriday.HTMLRendererParameters{
   396  		Flags: blackfriday.UseXHTML,
   397  	})
   398  	untrusted := blackfriday.Run(
   399  		[]byte(t),
   400  		blackfriday.WithRenderer(r),
   401  		blackfriday.WithExtensions(blackfriday.NoIntraEmphasis|blackfriday.FencedCode|blackfriday.Autolink))
   402  	out := bytes.NewBuffer(nil)
   403  	if err := sanitizehtml.Sanitize(out, bytes.NewReader(untrusted)); err != nil {
   404  		return template.HTML(fmt.Sprintf("Failed to render markdown: %s", template.HTMLEscapeString(err.Error())))
   405  	}
   406  	return template.HTML(out.String())
   407  }
   408  
   409  // sanitizeHTML sanitizes the given HTML.
   410  // Only a limited set of tags is supported. See sanitizehtml.Sanitize for
   411  // details.
   412  func sanitizeHTML(s string) (results template.HTML) {
   413  	out := bytes.NewBuffer(nil)
   414  	// TODO(crbug/1119896): replace sanitizehtml with safehtml once its sanitizer
   415  	// is exported.
   416  	sanitizehtml.Sanitize(out, strings.NewReader(s))
   417  	return template.HTML(out.String())
   418  }
   419  
   420  // pagedURL returns a self URL with the given cursor and limit paging options.
   421  // if limit is set to 0, then inherit whatever limit is set in request.  If
   422  // both are unspecified, then limit is omitted.
   423  func pagedURL(r *http.Request, limit int, cursor string) string {
   424  	if limit == 0 {
   425  		limit = GetLimit(r, -1)
   426  		if limit < 0 {
   427  			limit = 0
   428  		}
   429  	}
   430  	values := r.URL.Query()
   431  	switch cursor {
   432  	case "EMPTY":
   433  		values.Del("cursor")
   434  	case "":
   435  		// Do nothing, just leave the cursor in.
   436  	default:
   437  		values.Set("cursor", cursor)
   438  	}
   439  	switch {
   440  	case limit < 0:
   441  		values.Del("limit")
   442  	case limit > 0:
   443  		values.Set("limit", fmt.Sprintf("%d", limit))
   444  	}
   445  	result := *r.URL
   446  	result.RawQuery = values.Encode()
   447  	return result.String()
   448  }
   449  
   450  // percent divides one number by a divisor and returns the percentage in string form.
   451  func percent(numerator, divisor int) string {
   452  	p := float64(numerator) * 100.0 / float64(divisor)
   453  	return fmt.Sprintf("%.1f", p)
   454  }
   455  
   456  // faviconMIMEType derives the MIME type from a URL's file extension. Only valid
   457  // favicon image formats are supported.
   458  func faviconMIMEType(fileURL string) string {
   459  	switch {
   460  	case strings.HasSuffix(fileURL, ".png"):
   461  		return "image/png"
   462  	case strings.HasSuffix(fileURL, ".ico"):
   463  		return "image/ico"
   464  	case strings.HasSuffix(fileURL, ".jpeg"):
   465  		fallthrough
   466  	case strings.HasSuffix(fileURL, ".jpg"):
   467  		return "image/jpeg"
   468  	case strings.HasSuffix(fileURL, ".gif"):
   469  		return "image/gif"
   470  	}
   471  	return ""
   472  }
   473  
   474  // getTemplateBundles is used to render HTML templates. It provides base args
   475  // passed to all templates.  It takes a path to the template folder, relative
   476  // to the path of the binary during runtime.
   477  func getTemplateBundle(templatePath string, appVersionID string, prod bool) *templates.Bundle {
   478  	return &templates.Bundle{
   479  		Loader:          templates.FileSystemLoader(os.DirFS(templatePath)),
   480  		DebugMode:       func(c context.Context) bool { return !prod },
   481  		DefaultTemplate: "base",
   482  		DefaultArgs: func(c context.Context, e *templates.Extra) (templates.Args, error) {
   483  			loginURL, err := auth.LoginURL(c, e.Request.URL.RequestURI())
   484  			if err != nil {
   485  				return nil, err
   486  			}
   487  			logoutURL, err := auth.LogoutURL(c, e.Request.URL.RequestURI())
   488  			if err != nil {
   489  				return nil, err
   490  			}
   491  			var reload *int
   492  			if tReload := GetReload(e.Request, -1); tReload >= 0 {
   493  				reload = &tReload
   494  			}
   495  
   496  			project := e.Params.ByName("project")
   497  			group := e.Params.ByName("group")
   498  			return templates.Args{
   499  				"AppVersion":         appVersionID,
   500  				"IsAnonymous":        auth.CurrentIdentity(c) == identity.AnonymousIdentity,
   501  				"User":               auth.CurrentUser(c),
   502  				"LoginURL":           loginURL,
   503  				"LogoutURL":          logoutURL,
   504  				"CurrentTime":        clock.Now(c),
   505  				"GTMJSSnippet":       gtm.JSSnippet(c),
   506  				"GTMNoScriptSnippet": gtm.NoScriptSnippet(c),
   507  				"RequestID":          trace.SpanContextFromContext(c).TraceID().String(),
   508  				"Request":            e.Request,
   509  				"Navi":               ProjectLinks(c, project, group),
   510  				"ProjectID":          project,
   511  				"Reload":             reload,
   512  			}, nil
   513  		},
   514  		FuncMap: funcMap,
   515  	}
   516  }
   517  
   518  // withBuildbucketBuildsClient is a middleware that installs a production buildbucket builds RPC client into the context.
   519  func withBuildbucketBuildsClient(c *router.Context, next router.Handler) {
   520  	c.Request = c.Request.WithContext(buildbucket.WithBuildsClientFactory(c.Request.Context(), buildbucket.ProdBuildsClientFactory))
   521  	next(c)
   522  }
   523  
   524  // withGitMiddleware is a middleware that installs a prod Gerrit and Gitiles client
   525  // factory into the context. Both use Milo's credentials if current user is
   526  // has been granted read access in settings.cfg.
   527  //
   528  // This middleware must be installed after the auth middleware.
   529  func withGitMiddleware(c *router.Context, next router.Handler) {
   530  	acls, err := gitacls.FromConfig(c.Request.Context(), config.GetSettings(c.Request.Context()).SourceAcls)
   531  	if err != nil {
   532  		ErrorHandler(c, err)
   533  		return
   534  	}
   535  	c.Request = c.Request.WithContext(git.UseACLs(c.Request.Context(), acls))
   536  	next(c)
   537  }
   538  
   539  // builds a projectACLMiddleware, which expects c.Params to have project
   540  // parameter, adds ACL checks on a per-project basis, and install a git project
   541  // into context.
   542  // If optional is true, the returned middleware doesn't fail when the user has
   543  // no access to the project.
   544  func buildProjectACLMiddleware(optional bool) router.Middleware {
   545  	return func(c *router.Context, next router.Handler) {
   546  		luciProject := c.Params.ByName("project")
   547  		switch allowed, err := projectconfig.IsAllowed(c.Request.Context(), luciProject); {
   548  		case err != nil:
   549  			ErrorHandler(c, err)
   550  		case allowed:
   551  			c.Request = c.Request.WithContext(git.WithProject(c.Request.Context(), luciProject))
   552  			next(c)
   553  		case !allowed && optional:
   554  			next(c)
   555  		default:
   556  			if auth.CurrentIdentity(c.Request.Context()) == identity.AnonymousIdentity {
   557  				ErrorHandler(c, errors.New("not logged in", grpcutil.UnauthenticatedTag))
   558  			} else {
   559  				ErrorHandler(c, errors.New("no access to project", grpcutil.PermissionDeniedTag))
   560  			}
   561  		}
   562  	}
   563  }
   564  
   565  // ProjectLinks returns the navigation list surrounding a project and optionally group.
   566  func ProjectLinks(c context.Context, project, group string) []ui.LinkGroup {
   567  	if project == "" {
   568  		return nil
   569  	}
   570  	projLinks := []*ui.Link{
   571  		ui.NewLink(
   572  			"Builders",
   573  			fmt.Sprintf("/p/%s/builders", project),
   574  			fmt.Sprintf("All builders for project %s", project))}
   575  	links := []ui.LinkGroup{
   576  		{
   577  			Name: ui.NewLink(
   578  				project,
   579  				fmt.Sprintf("/p/%s", project),
   580  				fmt.Sprintf("Project page for %s", project)),
   581  			Links: projLinks,
   582  		},
   583  	}
   584  	if group != "" {
   585  		groupLinks := []*ui.Link{}
   586  		con, err := projectconfig.GetConsole(c, project, group)
   587  		if err != nil {
   588  			logging.WithError(err).Warningf(c, "error getting console")
   589  		} else if !con.Def.BuilderViewOnly {
   590  			groupLinks = append(groupLinks, ui.NewLink(
   591  				"Console",
   592  				fmt.Sprintf("/p/%s/g/%s/console", project, group),
   593  				fmt.Sprintf("Console for group %s in project %s", group, project)))
   594  		}
   595  
   596  		groupLinks = append(groupLinks, ui.NewLink(
   597  			"Builders",
   598  			fmt.Sprintf("/p/%s/g/%s/builders", project, group),
   599  			fmt.Sprintf("Builders for group %s in project %s", group, project)))
   600  
   601  		links = append(links, ui.LinkGroup{
   602  			Name:  ui.NewLink(group, "", ""),
   603  			Links: groupLinks,
   604  		})
   605  	}
   606  	return links
   607  }