blob: aed6ef25f05a06c69a93f2bd2d73e8ce931d79eb [file] [log] [blame]
// Copyright 2017 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package frontend
import (
"bytes"
"context"
"fmt"
"html/template"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/golang/protobuf/ptypes"
structpb "github.com/golang/protobuf/ptypes/struct"
"github.com/golang/protobuf/ptypes/timestamp"
blackfriday "gopkg.in/russross/blackfriday.v2"
"go.chromium.org/gae/service/info"
"go.chromium.org/luci/auth/identity"
buildbucketpb "go.chromium.org/luci/buildbucket/proto"
"go.chromium.org/luci/common/clock"
"go.chromium.org/luci/common/data/text/sanitizehtml"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/server/analytics"
"go.chromium.org/luci/server/auth"
"go.chromium.org/luci/server/router"
"go.chromium.org/luci/server/templates"
"go.chromium.org/luci/milo/buildsource/buildbot/buildstore"
"go.chromium.org/luci/milo/common"
"go.chromium.org/luci/milo/frontend/ui"
"go.chromium.org/luci/milo/git"
"go.chromium.org/luci/milo/git/gitacls"
)
// A collection of useful templating functions
// funcMap is what gets fed into the template bundle.
var funcMap = template.FuncMap{
"botLink": botLink,
"recipeLink": recipeLink,
"duration": duration,
"faviconMIMEType": faviconMIMEType,
"formatCommitDesc": formatCommitDesc,
"formatTime": formatTime,
"humanDuration": humanDuration,
"localTime": localTime,
"localTimeTooltip": localTimeTooltip,
"obfuscateEmail": obfuscateEmail,
"pagedURL": pagedURL,
"parseRFC3339": parseRFC3339,
"percent": percent,
"prefix": prefix,
"renderMarkdown": renderMarkdown,
"renderProperties": renderProperties,
"shortenEmail": shortenEmail,
"startswith": strings.HasPrefix,
"sub": sub,
"toInterval": toInterval,
"toLower": strings.ToLower,
"toTime": toTime,
}
// localTime returns a <span> element with t in human format
// that will be converted to local timezone in the browser.
// Recommended usage: {{ .Date | localTime "N/A" }}
func localTime(ifZero string, t time.Time) template.HTML {
return localTimeCommon(ifZero, t, "", t.Format(time.RFC850))
}
// localTimeTooltip is similar to localTime, but shows time in a tooltip and
// allows to specify inner text to be added to the created <span> element.
// Recommended usage: {{ .Date | localTimeTooltip "innerText" "N/A" }}
func localTimeTooltip(innerText string, ifZero string, t time.Time) template.HTML {
return localTimeCommon(ifZero, t, "tooltip-only", innerText)
}
func localTimeCommon(ifZero string, t time.Time, tooltipClass string, innerText string) template.HTML {
if t.IsZero() {
return template.HTML(template.HTMLEscapeString(ifZero))
}
milliseconds := t.UnixNano() / 1e6
return template.HTML(fmt.Sprintf(
`<span class="local-time %s" data-timestamp="%d">%s</span>`,
tooltipClass,
milliseconds,
template.HTMLEscapeString(innerText)))
}
// rURL matches anything that looks like an https:// URL.
var rURL = regexp.MustCompile(`\bhttps://\S*\b`)
// rBUGLINE matches a bug line in a commit, including if it is quoted.
// Expected formats: "BUG: 1234,1234", "bugs=1234", " > > BUG: 123"
var rBUGLINE = regexp.MustCompile(`(?m)^(>| )*(?i:bugs?)[:=].+$`)
// rBUG matches expected items in a bug line. Expected format: 12345, project:12345, #12345
var rBUG = regexp.MustCompile(`\b(\w+:)?#?\d+\b`)
// Expected formats: b/123456, crbug/123456, crbug/project/123456, crbug:123456, etc.
var rBUGLINK = regexp.MustCompile(`\b(b|crbug(\.com)?([:/]\w+)?)[:/]\d+\b`)
// tURL is a URL template.
var tURL = template.Must(template.New("tURL").Parse("<a href=\"{{.URL}}\">{{.Label}}</a>"))
// formatChunk is either an already-processed trusted template.HTML, produced
// by some previous regexp, or an untrusted string that still needs escaping or
// further processing by regexps. We keep track of the distinction to avoid
// escaping twice and rewrite rules applying inside HTML tags.
//
// At most one of str or html will be non-empty. That one is the field in use.
type formatChunk struct {
str string
html template.HTML
}
// replaceAllInChunks behaves like Regexp.ReplaceAllStringFunc, but it only
// acts on unprocessed elements of chunks. Already-processed elements are left
// as-is. repl returns trusted HTML, performing any necessary escaping.
func replaceAllInChunks(chunks []formatChunk, re *regexp.Regexp, repl func(string) template.HTML) []formatChunk {
var ret []formatChunk
for _, chunk := range chunks {
if len(chunk.html) != 0 {
ret = append(ret, chunk)
continue
}
s := chunk.str
for len(s) != 0 {
loc := re.FindStringIndex(s)
if loc == nil {
ret = append(ret, formatChunk{str: s})
break
}
if loc[0] > 0 {
ret = append(ret, formatChunk{str: s[:loc[0]]})
}
html := repl(s[loc[0]:loc[1]])
ret = append(ret, formatChunk{html: html})
s = s[loc[1]:]
}
}
return ret
}
// chunksToHTML concatenates chunks together, escaping as needed, to return a
// final completed HTML string.
func chunksToHTML(chunks []formatChunk) template.HTML {
buf := bytes.Buffer{}
for _, chunk := range chunks {
if len(chunk.html) != 0 {
buf.WriteString(string(chunk.html))
} else {
buf.WriteString(template.HTMLEscapeString(chunk.str))
}
}
return template.HTML(buf.String())
}
type link struct {
Label string
URL string
}
func makeLink(label, href string) template.HTML {
buf := bytes.Buffer{}
if err := tURL.Execute(&buf, link{label, href}); err != nil {
return template.HTML(template.HTMLEscapeString(label))
}
return template.HTML(buf.String())
}
func replaceLinkChunks(chunks []formatChunk) []formatChunk {
// Replace https:// URLs
chunks = replaceAllInChunks(chunks, rURL, func(s string) template.HTML {
return makeLink(s, s)
})
// Replace b/ and crbug/ URLs
chunks = replaceAllInChunks(chunks, rBUGLINK, func(s string) template.HTML {
// Normalize separator.
u := strings.Replace(s, ":", "/", -1)
u = strings.Replace(u, "crbug/", "crbug.com/", 1)
scheme := "https://"
if strings.HasPrefix(u, "b/") {
scheme = "http://"
}
return makeLink(s, scheme+u)
})
return chunks
}
// recipeLink generates a link to codesearch given a recipe bundle.
func recipeLink(r *buildbucketpb.BuildInfra_Recipe) (result template.HTML) {
if r == nil {
return
}
// We don't know location of recipes within the repo and getting that
// information is not trivial, so use code search, which is precise enough.
csHost := "cs.chromium.org"
if strings.HasPrefix(r.CipdPackage, "infra_internal") {
csHost = "cs.corp.google.com"
}
recipeURL := fmt.Sprintf("https://%s/search/?q=file:recipes/%s.py", csHost, r.Name)
return ui.NewLink(r.Name, recipeURL, fmt.Sprintf("recipe %s", r.Name)).HTML()
}
// botLink generates a link to a swarming bot given a buildbucketpb.BuildInfra_Swarming struct.
func botLink(s *buildbucketpb.BuildInfra_Swarming) (result template.HTML) {
for _, d := range s.GetBotDimensions() {
if d.Key == "id" {
return ui.NewLink(
d.Value,
fmt.Sprintf("https://%s/bot?id=%s", s.Hostname, d.Value),
fmt.Sprintf("swarming bot %s", d.Value)).HTML()
}
}
return ""
}
// formatCommitDesc takes a commit message and adds embellishments such as:
// * Linkify https:// URLs
// * Linkify bug numbers using https://crbug.com/
// * Linkify b/ bug links
// * Linkify crbug/ bug links
func formatCommitDesc(desc string) template.HTML {
chunks := []formatChunk{{str: desc}}
// Replace BUG: lines with URLs by rewriting all bug numbers with
// links. Run this first so later rules do not interfere with it. This
// allows a line like the following to work:
//
// Bug: https://crbug.com/1234, 5678
chunks = replaceAllInChunks(chunks, rBUGLINE, func(s string) template.HTML {
sChunks := []formatChunk{{str: s}}
// The call later in the parent function will not reach into
// sChunks, so run it separately.
sChunks = replaceLinkChunks(sChunks)
sChunks = replaceAllInChunks(sChunks, rBUG, func(sBug string) template.HTML {
path := strings.Replace(strings.Replace(sBug, "#", "", 1), ":", "/", 1)
return makeLink(sBug, "https://crbug.com/"+path)
})
return chunksToHTML(sChunks)
})
chunks = replaceLinkChunks(chunks)
return chunksToHTML(chunks)
}
// toTime returns the time.Time format for the proto timestamp.
// If the proto timestamp is invalid, we return a zero-ed out time.Time.
func toTime(ts *timestamp.Timestamp) (result time.Time) {
// We want a zero-ed out time.Time, not one set to the epoch.
if t, err := ptypes.Timestamp(ts); err == nil {
result = t
}
return
}
type interval struct {
Start time.Time
End time.Time
}
func (in interval) Started() bool {
return !in.Start.IsZero()
}
func (in interval) Ended() bool {
return !in.End.IsZero()
}
func (in interval) Duration() time.Duration {
// Only return something if the interval is complete.
if !(in.Ended() && in.Started()) {
return 0
}
// Don't return non-sensical values.
if d := in.End.Sub(in.Start); d > 0 {
return d
}
return 0
}
func toInterval(start, end *timestamp.Timestamp) (result interval) {
if t, err := ptypes.Timestamp(start); err == nil {
result.Start = t
}
if t, err := ptypes.Timestamp(end); err == nil {
result.End = t
}
return
}
func duration(start, end *timestamp.Timestamp) string {
in := toInterval(start, end)
if in.Started() && in.Ended() {
return humanDuration(in.Duration())
}
return "N/A"
}
// humanDuration translates d into a human readable string of x units y units,
// where x and y could be in days, hours, minutes, or seconds, whichever is the
// largest.
func humanDuration(d time.Duration) string {
t := int64(d.Seconds())
day := t / 86400
hr := (t % 86400) / 3600
if day > 0 {
if hr != 0 {
return fmt.Sprintf("%d days %d hrs", day, hr)
}
return fmt.Sprintf("%d days", day)
}
min := (t % 3600) / 60
if hr > 0 {
if min != 0 {
return fmt.Sprintf("%d hrs %d mins", hr, min)
}
return fmt.Sprintf("%d hrs", hr)
}
sec := t % 60
if min > 0 {
if sec != 0 {
return fmt.Sprintf("%d mins %d secs", min, sec)
}
return fmt.Sprintf("%d mins", min)
}
if sec != 0 {
return fmt.Sprintf("%d secs", sec)
}
if d > time.Millisecond {
return fmt.Sprintf("%d ms", d/time.Millisecond)
}
return "0"
}
// obfuscateEmail converts a string containing email adddress email@address.com
// into email<junk>@address.com.
func obfuscateEmail(email string) template.HTML {
email = template.HTMLEscapeString(email)
return template.HTML(strings.Replace(
email, "@", "<span style=\"display:none\">ohnoyoudont</span>@", -1))
}
// parseRFC3339 parses time represented as a RFC3339 or RFC3339Nano string.
// If cannot parse, returns zero time.
func parseRFC3339(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err == nil {
return t
}
t, err = time.Parse(time.RFC3339Nano, s)
if err == nil {
return t
}
return time.Time{}
}
// formatTime takes a time object and returns a formatted RFC3339 string.
func formatTime(t time.Time) string {
return t.Format(time.RFC3339)
}
// sub subtracts one number from another, because apparently go templates aren't
// smart enough to do that.
func sub(a, b int) int {
return a - b
}
// shortenEmail shortens Google emails.
func shortenEmail(email string) string {
return strings.Replace(email, "@google.com", "", -1)
}
// prefix abbriviates a string into specified number of characters.
// Recommended usage: {{ .GitHash | prefix 8 }}
func prefix(prefixLen int, s string) string {
if len(s) > prefixLen {
return s[:prefixLen]
}
return s
}
// GetLimit extracts the "limit", "numbuilds", or "num_builds" http param from
// the request, or returns def implying no limit was specified.
func GetLimit(r *http.Request, def int) int {
sLimit := r.FormValue("limit")
if sLimit == "" {
sLimit = r.FormValue("numbuilds")
if sLimit == "" {
sLimit = r.FormValue("num_builds")
if sLimit == "" {
return def
}
}
}
limit, err := strconv.Atoi(sLimit)
if err != nil || limit < 0 {
return def
}
return limit
}
// GetReload extracts the "reload" http param from the request,
// or returns def implying no limit was specified.
func GetReload(r *http.Request, def int) int {
sReload := r.FormValue("reload")
if sReload == "" {
return def
}
refresh, err := strconv.Atoi(sReload)
if err != nil || refresh < 0 {
return def
}
return refresh
}
var rLinkBreak = regexp.MustCompile("<br */?>")
// renderMarkdown renders the given text as markdown HTML.
// This uses blackfriday to convert from markdown to HTML,
// and sanitizehtml to allow only a small subset of HTML through.
func renderMarkdown(t string) (results template.HTML) {
buf := bytes.NewBuffer(blackfriday.Run([]byte(t)))
out := bytes.NewBuffer(nil)
if err := sanitizehtml.Sanitize(out, buf); err != nil {
return template.HTML(fmt.Sprintf("Failed to render markdown: %s", template.HTMLEscapeString(err.Error())))
}
return template.HTML(out.String())
}
// renderProperties renders a structpb.Struct as a properties table.
// TODO(hinoka): Implement me.
func renderProperties(p *structpb.Struct) (results template.HTML) {
if p == nil {
return
}
return
}
// pagedURL returns a self URL with the given cursor and limit paging options.
// if limit is set to 0, then inherit whatever limit is set in request. If
// both are unspecified, then limit is omitted.
func pagedURL(r *http.Request, limit int, cursor string) string {
if limit == 0 {
limit = GetLimit(r, -1)
if limit < 0 {
limit = 0
}
}
values := r.URL.Query()
switch cursor {
case "EMPTY":
values.Del("cursor")
case "":
// Do nothing, just leave the cursor in.
default:
values.Set("cursor", cursor)
}
switch {
case limit < 0:
values.Del("limit")
case limit > 0:
values.Set("limit", fmt.Sprintf("%d", limit))
}
result := *r.URL
result.RawQuery = values.Encode()
return result.String()
}
// percent divides one number by a divisor and returns the percentage in string form.
func percent(numerator, divisor int) string {
p := float64(numerator) * 100.0 / float64(divisor)
return fmt.Sprintf("%.1f", p)
}
// faviconMIMEType derives the MIME type from a URL's file extension. Only valid
// favicon image formats are supported.
func faviconMIMEType(fileURL string) string {
switch {
case strings.HasSuffix(fileURL, ".png"):
return "image/png"
case strings.HasSuffix(fileURL, ".ico"):
return "image/ico"
case strings.HasSuffix(fileURL, ".jpeg"):
fallthrough
case strings.HasSuffix(fileURL, ".jpg"):
return "image/jpeg"
case strings.HasSuffix(fileURL, ".gif"):
return "image/gif"
}
return ""
}
// getTemplateBundles is used to render HTML templates. It provides base args
// passed to all templates. It takes a path to the template folder, relative
// to the path of the binary during runtime.
func getTemplateBundle(templatePath string) *templates.Bundle {
return &templates.Bundle{
Loader: templates.FileSystemLoader(templatePath),
DebugMode: info.IsDevAppServer,
DefaultTemplate: "base",
DefaultArgs: func(c context.Context, e *templates.Extra) (templates.Args, error) {
loginURL, err := auth.LoginURL(c, e.Request.URL.RequestURI())
if err != nil {
return nil, err
}
logoutURL, err := auth.LogoutURL(c, e.Request.URL.RequestURI())
if err != nil {
return nil, err
}
project := e.Params.ByName("project")
group := e.Params.ByName("group")
return templates.Args{
"AppVersion": strings.Split(info.VersionID(c), ".")[0],
"IsAnonymous": auth.CurrentIdentity(c) == identity.AnonymousIdentity,
"User": auth.CurrentUser(c),
"LoginURL": loginURL,
"LogoutURL": logoutURL,
"CurrentTime": clock.Now(c),
"Analytics": analytics.Snippet(c),
"RequestID": info.RequestID(c),
"Request": e.Request,
"Navi": ProjectLinks(c, project, group),
"ProjectID": project,
}, nil
},
FuncMap: funcMap,
}
}
// withGitMiddleware is a middleware that installs a prod Gerrit and Gitiles client
// factory into the context. Both use Milo's credentials if current user is
// has been granted read access in settings.cfg.
//
// This middleware must be installed after the auth middleware.
func withGitMiddleware(c *router.Context, next router.Handler) {
acls, err := gitacls.FromConfig(c.Context, common.GetSettings(c.Context).SourceAcls)
if err != nil {
ErrorHandler(c, err)
return
}
c.Context = git.UseACLs(c.Context, acls)
next(c)
}
// withAccessClientMiddleware is a middleware that installs a prod buildbucket
// access API client into the context.
//
// This middleware depends on auth middleware in order to generate the access
// client, so it must be called after the auth middleware is installed.
func withAccessClientMiddleware(c *router.Context, next router.Handler) {
client, err := common.NewAccessClient(c.Context)
if err != nil {
ErrorHandler(c, err)
return
}
c.Context = common.WithAccessClient(c.Context, client)
next(c)
}
// projectACLMiddleware adds ACL checks on a per-project basis.
// Expects c.Params to have project parameter.
func projectACLMiddleware(c *router.Context, next router.Handler) {
switch allowed, err := common.IsAllowed(c.Context, c.Params.ByName("project")); {
case err != nil:
ErrorHandler(c, err)
case !allowed:
if auth.CurrentIdentity(c.Context) == identity.AnonymousIdentity {
ErrorHandler(c, errors.New("not logged in", common.CodeUnauthorized))
} else {
ErrorHandler(c, errors.New("no access to project", common.CodeNoAccess))
}
default:
next(c)
}
}
// emulationMiddleware enables buildstore emulation if "emulation" query
// string parameter is not empty.
func emulationMiddleware(c *router.Context, next router.Handler) {
c.Context = buildstore.WithEmulation(c.Context, c.Request.FormValue("emulation") != "")
next(c)
}
// ProjectLinks returns the navigation list surrounding a project and optionally group.
func ProjectLinks(c context.Context, project, group string) []ui.LinkGroup {
if project == "" {
return nil
}
projLinks := []*ui.Link{
ui.NewLink(
"Builders",
fmt.Sprintf("/p/%s/builders", project),
fmt.Sprintf("All builders for project %s", project))}
links := []ui.LinkGroup{
{
Name: ui.NewLink(
project,
fmt.Sprintf("/p/%s", project),
fmt.Sprintf("Project page for %s", project)),
Links: projLinks,
},
}
if group != "" {
groupLinks := []*ui.Link{}
con, err := common.GetConsole(c, project, group)
if err != nil {
logging.WithError(err).Warningf(c, "error getting console")
} else if !con.Def.BuilderViewOnly {
groupLinks = append(groupLinks, ui.NewLink(
"Console",
fmt.Sprintf("/p/%s/g/%s/console", project, group),
fmt.Sprintf("Console for group %s in project %s", group, project)))
}
groupLinks = append(groupLinks, ui.NewLink(
"Builders",
fmt.Sprintf("/p/%s/g/%s/builders", project, group),
fmt.Sprintf("Builders for group %s in project %s", group, project)))
links = append(links, ui.LinkGroup{
Name: ui.NewLink(group, "", ""),
Links: groupLinks,
})
}
return links
}