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 }