blob: 05b6e9ff0d7c878b14d59d0339516c0f4fe10e48 [file] [log] [blame]
// Copyright 2015 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 (
"fmt"
"html/template"
"net/http"
"net/url"
"strings"
"time"
"github.com/julienschmidt/httprouter"
"go.chromium.org/luci/auth/identity"
bbv1 "go.chromium.org/luci/common/api/buildbucket/buildbucket/v1"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/server"
"go.chromium.org/luci/server/auth"
"go.chromium.org/luci/server/auth/openid"
"go.chromium.org/luci/server/auth/xsrf"
"go.chromium.org/luci/server/middleware"
"go.chromium.org/luci/server/router"
"go.chromium.org/luci/server/templates"
"go.chromium.org/luci/milo/buildsource/buildbucket"
"go.chromium.org/luci/milo/buildsource/swarming"
"go.chromium.org/luci/milo/common"
)
// Run sets up all the routes and runs the server.
func Run(srv *server.Server, templatePath string) {
appVersionID := "unknown"
if idx := strings.LastIndex(srv.Options.ContainerImageID, ":"); idx != -1 {
appVersionID = srv.Options.ContainerImageID[idx+1:]
}
// Register plain ol' http handlers.
r := srv.Routes
baseMW := router.NewMiddlewareChain()
baseAuthMW := baseMW.Extend(
middleware.WithContextTimeout(time.Minute),
auth.Authenticate(srv.CookieAuth),
)
htmlMW := baseAuthMW.Extend(
withAccessClientMiddleware, // This must be called after the auth.Authenticate middleware.
withGitMiddleware,
withBuildbucketBuildsClient,
withBuildbucketBuildersClient,
templates.WithTemplates(getTemplateBundle(templatePath, appVersionID, srv.Options.Prod)),
)
xsrfMW := htmlMW.Extend(xsrf.WithTokenCheck)
projectMW := htmlMW.Extend(buildProjectACLMiddleware(false))
optionalProjectMW := htmlMW.Extend(buildProjectACLMiddleware(true))
r.GET("/", htmlMW, frontpageHandler)
r.GET("/p", baseMW, movedPermanently("/"))
r.GET("/search", htmlMW, handleError(searchHandler))
r.GET("/opensearch.xml", baseMW, searchXMLHandler)
// Artifacts.
r.GET("/artifact/*path", baseMW, redirect("/ui/artifact/*path", http.StatusFound))
// Invocations.
r.GET("/inv/*path", baseMW, redirect("/ui/inv/*path", http.StatusFound))
// Builds.
r.GET("/b/:id", htmlMW, handleError(redirectLUCIBuild))
r.GET("/p/:project/builds/b:id", baseMW, movedPermanently("/b/:id"))
buildPageMW := router.NewMiddlewareChain(func(c *router.Context, next router.Handler) {
shouldShowNewBuildPage := getShowNewBuildPageCookie(c)
if shouldShowNewBuildPage {
redirect("/ui/p/:project/builders/:bucket/:builder/:numberOrId", http.StatusFound)(c)
} else {
next(c)
}
}).Extend(optionalProjectMW...)
r.GET("/p/:project/builders/:bucket/:builder/:numberOrId", buildPageMW, handleError(handleLUCIBuild))
// TODO(crbug/1108198): remvoe this route once we turned down the old build page.
r.GET("/old/p/:project/builders/:bucket/:builder/:numberOrId", optionalProjectMW, handleError(handleLUCIBuild))
// Only the new build page can take path suffix, redirect to the new build page.
r.GET("/b/:id/*path", baseMW, redirect("/ui/b/:id/*path", http.StatusFound))
r.GET("/p/:project/builds/b:id/*path", baseMW, redirect("/ui/b/:id/*path", http.StatusFound))
r.GET("/p/:project/builders/:bucket/:builder/:numberOrId/*path", baseMW, redirect("/ui/p/:project/builders/:bucket/:builder/:numberOrId/*path", http.StatusFound))
// Console
r.GET("/p/:project", projectMW, handleError(func(c *router.Context) error {
return ConsolesHandler(c, c.Params.ByName("project"))
}))
r.GET("/p/:project/", baseMW, movedPermanently("/p/:project"))
r.GET("/p/:project/g", baseMW, movedPermanently("/p/:project"))
r.GET("/p/:project/g/:group/console", projectMW, handleError(ConsoleHandler))
r.GET("/p/:project/g/:group", projectMW, redirect("/p/:project/g/:group/console", http.StatusFound))
r.GET("/p/:project/g/:group/", baseMW, movedPermanently("/p/:project/g/:group"))
// Builder list
// Redirects to the lit-element implementation.
r.GET("/p/:project/builders", baseMW, redirect("/ui/p/:project/builders", http.StatusFound))
r.GET("/p/:project/g/:group/builders", baseMW, redirect("/ui/p/:project/g/:group/builders", http.StatusFound))
// Swarming
r.GET(swarming.URLBase+"/:id/steps/*logname", htmlMW, handleError(HandleSwarmingLog))
r.GET(swarming.URLBase+"/:id", htmlMW, handleError(handleSwarmingBuild))
// Backward-compatible URLs for Swarming:
r.GET("/swarming/prod/:id/steps/*logname", htmlMW, handleError(HandleSwarmingLog))
r.GET("/swarming/prod/:id", htmlMW, handleError(handleSwarmingBuild))
// Buildbucket
// If these routes change, also change links in common/model/build_summary.go:getLinkFromBuildID
// and common/model/builder_summary.go:SelfLink.
r.GET("/p/:project/builders/:bucket/:builder", optionalProjectMW, handleError(BuilderHandler))
r.GET("/buildbucket/:bucket/:builder", baseMW, redirectFromProjectlessBuilder)
// LogDog Milo Annotation Streams.
// This mimics the `logdog://logdog_host/project/*path` url scheme seen on
// swarming tasks.
r.GET("/raw/build/:logdog_host/:project/*path", htmlMW, handleError(handleRawPresentationBuild))
pubsubMW := router.NewMiddlewareChain(
auth.Authenticate(&openid.GoogleIDTokenAuthMethod{
AudienceCheck: openid.AudienceMatchesHost,
}),
withBuildbucketBuildsClient,
)
pusherID := identity.Identity(fmt.Sprintf("user:buildbucket-pubsub@%s.iam.gserviceaccount.com", srv.Options.CloudProject))
// PubSub subscription endpoints.
r.POST("/push-handlers/buildbucket", pubsubMW, func(ctx *router.Context) {
if got := auth.CurrentIdentity(ctx.Context); got != pusherID {
logging.Errorf(ctx.Context, "Expecting ID token of %q, got %q", pusherID, got)
ctx.Writer.WriteHeader(403)
} else {
buildbucket.PubSubHandler(ctx)
}
})
r.POST("/actions/cancel_build", xsrfMW, handleError(cancelBuildHandler))
r.POST("/actions/retry_build", xsrfMW, handleError(retryBuildHandler))
r.GET("/internal_widgets/related_builds/:id", htmlMW, handleError(handleGetRelatedBuildsTable))
// Config for ResultUI frontend.
r.GET("/configs.js", baseMW, handleError(configsJSHandler))
r.GET("/auth-state", baseAuthMW, handleError(getAuthState))
}
// handleError is a wrapper for a handler so that the handler can return an error
// rather than call ErrorHandler directly.
// This should be used for handlers that render webpages.
func handleError(handler func(c *router.Context) error) func(c *router.Context) {
return func(c *router.Context) {
if err := handler(c); err != nil {
ErrorHandler(c, err)
}
}
}
// redirect returns a handler that responds with given HTTP status
// with a location specified by the pathTemplate.
func redirect(pathTemplate string, status int) router.Handler {
if !strings.HasPrefix(pathTemplate, "/") {
panic("pathTemplate must start with /")
}
interpolator := createInterpolator(pathTemplate)
return func(c *router.Context) {
path := interpolator(c.Params)
http.Redirect(c.Writer, c.Request, path, status)
}
}
// createInterpolator returns a function that can replace the variables in the
// pathTemplate with the provided params.
func createInterpolator(pathTemplate string) func(params httprouter.Params) string {
templateParts := strings.Split(pathTemplate, "/")
return func(params httprouter.Params) string {
components := make([]string, 0, len(templateParts))
for _, p := range templateParts {
if strings.HasPrefix(p, ":") {
components = append(components, params.ByName(p[1:]))
} else if strings.HasPrefix(p, "*_") {
// httprouter uses the decoded URL path to perform routing
// (which defeats the whole purpose of encoding), so we have to
// use '*' to capture a path component containing %2F.
// "*_" is a special syntax to signal that although we are
// capturing all characters till the end of the path, the
// captured value should be treated as a single path component,
// therefore '/' should also be encoded.
//
// Caveat: because '*' is used, this hack only works for the
// last path component.
//
// https://github.com/julienschmidt/httprouter/issues/284
component := params.ByName(p[1:])
component = strings.TrimPrefix(component, "/")
components = append(components, component)
} else if strings.HasPrefix(p, "*") {
path := params.ByName(p[1:])
path = strings.TrimPrefix(path, "/")
// Split the path into components before passing them to
// url.PathEscape. Otherwise url.PathEscape will encode "/" into
// "%2F" because it escapes all non-safe characters in a path
// component (it should be renamed to url.PathComponentEscape).
components = append(components, strings.Split(path, "/")...)
} else {
components = append(components, p)
}
}
// Escape the path components ourselves.
// url.URL.String() should not be used because it escapes everything
// automatically except '/' making it impossible to have %2F (encoded
// '/') in a path component ('%2F' will be double encoded to '%252F'
// while '/' won't be encoded at all).
for i, p := range components {
components[i] = url.PathEscape(p)
}
return strings.Join(components, "/")
}
}
// movedPermanently is a special instance of redirect, returning a handler
// that responds with HTTP 301 (Moved Permanently) with a location specified
// by the pathTemplate.
//
// TODO(nodir,iannucci): delete all usages.
func movedPermanently(pathTemplate string) router.Handler {
return redirect(pathTemplate, http.StatusMovedPermanently)
}
func redirectFromProjectlessBuilder(c *router.Context) {
bucket := c.Params.ByName("bucket")
builder := c.Params.ByName("builder")
project, _ := bbv1.BucketNameToV2(bucket)
u := *c.Request.URL
u.Path = fmt.Sprintf("/p/%s/builders/%s/%s", project, bucket, builder)
http.Redirect(c.Writer, c.Request, u.String(), http.StatusMovedPermanently)
}
// configsJSHandler serves /configs.js used by ResultUI frontend code.
func configsJSHandler(c *router.Context) error {
template, err := template.ParseFiles("templates/configs.template.js")
if err != nil {
logging.Errorf(c.Context, "Failed to load configs.template.js: %s", err)
return err
}
settings := common.GetSettings(c.Context)
header := c.Writer.Header()
header.Set("content-type", "application/javascript")
// The configs file rarely changes, and may block other scripts from running.
// Set max-age to one hour, stale-while-revalidate to 7 days to improve
// performance.
header.Set("cache-control", "max-age=3600,stale-while-revalidate=604800")
err = template.Execute(c.Writer, map[string]interface{}{
"ResultDB": map[string]string{
"Host": settings.GetResultdb().GetHost(),
},
"Buildbucket": map[string]string{
"Host": settings.GetBuildbucket().GetHost(),
},
})
if err != nil {
logging.Errorf(c.Context, "Failed to execute configs.template.js: %s", err)
return err
}
return nil
}