blob: b728c2296285f8e33e1cd47a5a8894d3740525a3 [file] [log] [blame]
// Copyright 2017 The LUCI Authors. All rights reserved.
// Use of this source code is governed under the Apache License, Version 2.0
// that can be found in the LICENSE file.
package frontend
import (
"bytes"
"fmt"
"html/template"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/net/context"
"github.com/luci/gae/service/info"
"github.com/luci/luci-go/appengine/gaeauth/server"
"github.com/luci/luci-go/appengine/gaemiddleware"
"github.com/luci/luci-go/common/clock"
"github.com/luci/luci-go/server/analytics"
"github.com/luci/luci-go/server/auth"
"github.com/luci/luci-go/server/auth/identity"
"github.com/luci/luci-go/server/router"
"github.com/luci/luci-go/server/templates"
"github.com/luci/luci-go/milo/api/resp"
)
// A collection of useful templating functions
// funcMap is what gets fed into the template bundle.
var funcMap = template.FuncMap{
"humanDuration": humanDuration,
"parseRFC3339": parseRFC3339,
"linkify": linkify,
"linkifySet": linkifySet,
"obfuscateEmail": obfuscateEmail,
"localTime": localTime,
"shortHash": shortHash,
"startswith": strings.HasPrefix,
"sub": sub,
"consoleHeader": consoleHeader,
"pagedURL": pagedURL,
"formatTime": formatTime,
"percent": percent,
}
// 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 {
if t.IsZero() {
return template.HTML(template.HTMLEscapeString(ifZero))
}
milliseconds := t.UnixNano() / 1e6
return template.HTML(fmt.Sprintf(
`<span class="local-time" data-timestamp="%d">%s</span>`,
milliseconds,
t.Format(time.RFC850)))
}
func consoleHeader(brs []resp.BuilderRef) template.HTML {
// First, split things into nice rows and find the max depth.
cat := make([][]string, len(brs))
depth := 0
for i, b := range brs {
cat[i] = b.Category
if len(cat[i]) > depth {
depth = len(cat[i])
}
}
result := ""
for row := 0; row < depth; row++ {
result += "<tr><th></th>"
// "" is the first node, " " is an empty node.
current := ""
colspan := 0
for _, br := range cat {
colspan++
var s string
if row >= len(br) {
s = " "
} else {
s = br[row]
}
if s != current || current == " " {
if current != "" || current == " " {
result += fmt.Sprintf(`<th colspan="%d">%s</th>`, colspan, current)
colspan = 0
}
current = s
}
}
if colspan != 0 {
result += fmt.Sprintf(`<th colspan="%d">%s</th>`, colspan, current)
}
result += "</tr>"
}
// Last row: The actual builder shortnames.
result += "<tr><th></th>"
for _, br := range brs {
result += fmt.Sprintf("<th>%s</th>", br.ShortName)
}
result += "</tr>"
return template.HTML(result)
}
// 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)
}
// linkifyTemplate is the template used in "linkify". Because the template,
// itself recursively invokes "linkify", we will initialize it in explicitly
// in "init()".
//
// linkifySetTemplate is the template used in "linkifySet".
var linkifyTemplate, linkifySetTemplate *template.Template
// linkify turns a resp.LinkSet struct into a canonical link.
func linkify(link *resp.Link) template.HTML {
if link == nil {
return ""
}
buf := bytes.Buffer{}
if err := linkifyTemplate.Execute(&buf, link); err != nil {
panic(err)
}
return template.HTML(buf.Bytes())
}
// linkifySet turns a resp.Link struct into a canonical link.
func linkifySet(linkSet resp.LinkSet) template.HTML {
if len(linkSet) == 0 {
return ""
}
buf := bytes.Buffer{}
if err := linkifySetTemplate.Execute(&buf, linkSet); err != nil {
panic(err)
}
return template.HTML(buf.Bytes())
}
// 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
}
// shortHash abbriviates a git hash into 6 characters.
func shortHash(s string) string {
if len(s) > 6 {
return s[0:6]
}
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
}
// 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)
}
func init() {
linkifySetTemplate = template.Must(
template.New("linkifySet").
Funcs(template.FuncMap{
"linkify": linkify,
}).Parse(
`{{ range $i, $link := . }}` +
`{{ if gt $i 0 }} {{ end }}` +
`{{ $link | linkify}}` +
`{{ end }}`))
linkifyTemplate = template.Must(
template.New("linkify").
Parse(
`<a href="{{.URL}}">` +
`{{if .Img}}<img src="{{.Img}}"{{if .Alt}} alt="{{.Alt}}"{{end}}>` +
`{{else if .Alias}}[{{.Label}}]` +
`{{else}}{{.Label}}{{end}}` +
`</a>`))
}
var authconfig *auth.Config
// 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) (templates.Args, error) {
r := getRequest(c)
path := r.URL.Path
loginURL, err := auth.LoginURL(c, path)
if err != nil {
return nil, err
}
logoutURL, err := auth.LogoutURL(c, path)
if err != nil {
return nil, err
}
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": r,
}, nil
},
FuncMap: funcMap,
}
}
// base returns the basic LUCI appengine middlewares.
func base(templatePath string) router.MiddlewareChain {
return gaemiddleware.BaseProd().Extend(
auth.Authenticate(server.CookieAuth),
withRequestMiddleware,
templates.WithTemplates(getTemplateBundle(templatePath)),
)
}
// The context key, so that we can embed the http.Request object into
// the context.
var requestKey = "http.request"
// withRequest returns a context with the http.Request object
// in it.
func withRequest(c context.Context, r *http.Request) context.Context {
return context.WithValue(c, &requestKey, r)
}
// withRequestMiddleware is a middleware that installs a request into the context.
// This is used for various things in the default template.
func withRequestMiddleware(c *router.Context, next router.Handler) {
c.Context = withRequest(c.Context, c.Request)
next(c)
}
func getRequest(c context.Context) *http.Request {
if req, ok := c.Value(&requestKey).(*http.Request); ok {
return req
}
panic("No http.request found in context")
}