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

     1  // Copyright 2015 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  	"fmt"
    19  	"net/http"
    20  	"net/url"
    21  	"strings"
    22  	"time"
    23  
    24  	"github.com/julienschmidt/httprouter"
    25  
    26  	"go.chromium.org/luci/auth/identity"
    27  	bbv1 "go.chromium.org/luci/common/api/buildbucket/buildbucket/v1"
    28  	"go.chromium.org/luci/common/logging"
    29  	"go.chromium.org/luci/milo/internal/buildsource/buildbucket"
    30  	"go.chromium.org/luci/milo/internal/buildsource/swarming"
    31  	"go.chromium.org/luci/server"
    32  	"go.chromium.org/luci/server/auth"
    33  	"go.chromium.org/luci/server/auth/openid"
    34  	"go.chromium.org/luci/server/auth/xsrf"
    35  	"go.chromium.org/luci/server/middleware"
    36  	"go.chromium.org/luci/server/router"
    37  	"go.chromium.org/luci/server/templates"
    38  )
    39  
    40  // Run sets up all the routes and runs the server.
    41  func Run(srv *server.Server, templatePath string) {
    42  	// Register plain ol' http handlers.
    43  	r := srv.Routes
    44  
    45  	baseMW := router.NewMiddlewareChain()
    46  	baseAuthMW := baseMW.Extend(
    47  		middleware.WithContextTimeout(time.Minute),
    48  		auth.Authenticate(srv.CookieAuth),
    49  	)
    50  	htmlMW := baseAuthMW.Extend(
    51  		withGitMiddleware,
    52  		withBuildbucketBuildsClient,
    53  		templates.WithTemplates(getTemplateBundle(templatePath, srv.Options.ImageVersion(), srv.Options.Prod)),
    54  	)
    55  	xsrfMW := htmlMW.Extend(xsrf.WithTokenCheck)
    56  	projectMW := htmlMW.Extend(buildProjectACLMiddleware(false))
    57  	optionalProjectMW := htmlMW.Extend(buildProjectACLMiddleware(true))
    58  
    59  	r.GET("/", htmlMW, redirect("/ui/", http.StatusFound))
    60  	r.GET("/p", baseMW, movedPermanently("/"))
    61  	r.GET("/search", htmlMW, redirect("/ui/search", http.StatusFound))
    62  	r.GET("/opensearch.xml", baseMW, searchXMLHandler)
    63  
    64  	// Artifacts.
    65  	r.GET("/artifact/*path", baseMW, redirect("/ui/artifact/*path", http.StatusFound))
    66  
    67  	// Invocations.
    68  	r.GET("/inv/*path", baseMW, redirect("/ui/inv/*path", http.StatusFound))
    69  
    70  	// Builds.
    71  	r.GET("/b/:id", htmlMW, handleError(redirectLUCIBuild))
    72  	r.GET("/p/:project/builds/b:id", baseMW, movedPermanently("/b/:id"))
    73  
    74  	buildPageMW := router.NewMiddlewareChain(func(c *router.Context, next router.Handler) {
    75  		shouldShowNewBuildPage := getShowNewBuildPageCookie(c)
    76  		if shouldShowNewBuildPage {
    77  			redirect("/ui/p/:project/builders/:bucket/:builder/:numberOrId", http.StatusFound)(c)
    78  		} else {
    79  			next(c)
    80  		}
    81  	}).Extend(optionalProjectMW...)
    82  	r.GET("/p/:project/builders/:bucket/:builder/:numberOrId", buildPageMW, handleError(handleLUCIBuild))
    83  	// TODO(crbug/1108198): remove this route once we turned down the old build page.
    84  	r.GET("/old/p/:project/builders/:bucket/:builder/:numberOrId", optionalProjectMW, handleError(handleLUCIBuild))
    85  
    86  	// Only the new build page can take path suffix, redirect to the new build page.
    87  	r.GET("/b/:id/*path", baseMW, redirect("/ui/b/:id/*path", http.StatusFound))
    88  	r.GET("/p/:project/builds/b:id/*path", baseMW, redirect("/ui/b/:id/*path", http.StatusFound))
    89  	r.GET("/p/:project/builders/:bucket/:builder/:numberOrId/*path", baseMW, redirect("/ui/p/:project/builders/:bucket/:builder/:numberOrId/*path", http.StatusFound))
    90  
    91  	// Console
    92  	r.GET("/p/:project", projectMW, redirect("/ui/p/:project", http.StatusFound))
    93  	r.GET("/p/:project/", baseMW, movedPermanently("/p/:project"))
    94  	r.GET("/p/:project/g", baseMW, movedPermanently("/p/:project"))
    95  	r.GET("/p/:project/g/:group/console", projectMW, handleError(ConsoleHandler))
    96  	r.GET("/p/:project/g/:group", projectMW, redirect("/p/:project/g/:group/console", http.StatusFound))
    97  	r.GET("/p/:project/g/:group/", baseMW, movedPermanently("/p/:project/g/:group"))
    98  
    99  	// Builder list
   100  	// Redirects to the SPA implementation.
   101  	r.GET("/p/:project/builders", baseMW, redirect("/ui/p/:project/builders", http.StatusFound))
   102  	r.GET("/p/:project/g/:group/builders", baseMW, redirect("/ui/p/:project/g/:group/builders", http.StatusFound))
   103  
   104  	// Swarming
   105  	r.GET(swarming.URLBase+"/:id", htmlMW, handleError(handleSwarmingBuild))
   106  	// Backward-compatible URLs for Swarming:
   107  	r.GET("/swarming/prod/:id", htmlMW, handleError(handleSwarmingBuild))
   108  
   109  	// Buildbucket
   110  	// If these routes change, also change links in
   111  	// common/model/builder_summary.go:SelfLink.
   112  	// Redirects to the SPA implementation.
   113  	r.GET("/p/:project/builders/:bucket/:builder", baseMW, redirect("/ui/p/:project/builders/:bucket/:builder", http.StatusFound))
   114  
   115  	r.GET("/buildbucket/:bucket/:builder", baseMW, redirectFromProjectlessBuilder)
   116  
   117  	// LogDog Milo Annotation Streams.
   118  	// This mimics the `logdog://logdog_host/project/*path` url scheme seen on
   119  	// swarming tasks.
   120  	r.GET("/raw/build/:logdog_host/:project/*path", htmlMW, handleError(handleRawPresentationBuild))
   121  
   122  	pubsubMW := router.NewMiddlewareChain(
   123  		auth.Authenticate(&openid.GoogleIDTokenAuthMethod{
   124  			AudienceCheck: openid.AudienceMatchesHost,
   125  		}),
   126  		withBuildbucketBuildsClient,
   127  	)
   128  	pusherID := identity.Identity(fmt.Sprintf("user:buildbucket-pubsub@%s.iam.gserviceaccount.com", srv.Options.CloudProject))
   129  
   130  	// PubSub subscription endpoints.
   131  	// TODO(b/40254169): remove this once v1 subscription is turned down.
   132  	r.POST("/push-handlers/buildbucket", pubsubMW, func(ctx *router.Context) {
   133  		if got := auth.CurrentIdentity(ctx.Request.Context()); got != pusherID {
   134  			logging.Errorf(ctx.Request.Context(), "Expecting ID token of %q, got %q", pusherID, got)
   135  			ctx.Writer.WriteHeader(403)
   136  		} else {
   137  			buildbucket.PubSubHandler(ctx)
   138  		}
   139  	})
   140  	// PubSub subscription endpoints.
   141  	r.POST("/push-handlers/buildbucket_v2", pubsubMW, func(ctx *router.Context) {
   142  		if got := auth.CurrentIdentity(ctx.Request.Context()); got != pusherID {
   143  			logging.Errorf(ctx.Request.Context(), "Expecting ID token of %q, got %q", pusherID, got)
   144  			ctx.Writer.WriteHeader(403)
   145  		} else {
   146  			buildbucket.V2PubSubHandler(ctx)
   147  		}
   148  	})
   149  
   150  	r.POST("/actions/cancel_build", xsrfMW, handleError(cancelBuildHandler))
   151  	r.POST("/actions/retry_build", xsrfMW, handleError(retryBuildHandler))
   152  
   153  	r.GET("/internal_widgets/related_builds/:id", htmlMW, handleError(handleGetRelatedBuildsTable))
   154  }
   155  
   156  // redirect returns a handler that responds with given HTTP status
   157  // with a location specified by the pathTemplate.
   158  func redirect(pathTemplate string, status int) router.Handler {
   159  	if !strings.HasPrefix(pathTemplate, "/") {
   160  		panic("pathTemplate must start with /")
   161  	}
   162  
   163  	interpolator := createInterpolator(pathTemplate)
   164  	return func(c *router.Context) {
   165  		path := interpolator(c.Params)
   166  		targetURL := *c.Request.URL
   167  
   168  		// Use RawPath instead of Path to ensure the path is not doubled encoded.
   169  		targetURL.RawPath = path
   170  		unescaped, err := url.PathUnescape(path)
   171  		if err != nil {
   172  			// The URL returned from interpolator is always in the escaped form.
   173  			panic(err)
   174  		}
   175  		targetURL.Path = unescaped
   176  
   177  		http.Redirect(c.Writer, c.Request, targetURL.String(), status)
   178  	}
   179  }
   180  
   181  // createInterpolator returns a function that can replace the variables in the
   182  // pathTemplate with the provided params.
   183  func createInterpolator(pathTemplate string) func(params httprouter.Params) string {
   184  	templateParts := strings.Split(pathTemplate, "/")
   185  
   186  	return func(params httprouter.Params) string {
   187  		components := make([]string, 0, len(templateParts))
   188  
   189  		for _, p := range templateParts {
   190  			if strings.HasPrefix(p, ":") {
   191  				components = append(components, params.ByName(p[1:]))
   192  			} else if strings.HasPrefix(p, "*_") {
   193  				// httprouter uses the decoded URL path to perform routing
   194  				// (which defeats the whole purpose of encoding), so we have to
   195  				// use '*' to capture a path component containing %2F.
   196  				// "*_" is a special syntax to signal that although we are
   197  				// capturing all characters till the end of the path, the
   198  				// captured value should be treated as a single path component,
   199  				// therefore '/' should also be encoded.
   200  				//
   201  				// Caveat: because '*' is used, this hack only works for the
   202  				// last path component.
   203  				//
   204  				// https://github.com/julienschmidt/httprouter/issues/284
   205  				component := params.ByName(p[1:])
   206  				component = strings.TrimPrefix(component, "/")
   207  				components = append(components, component)
   208  			} else if strings.HasPrefix(p, "*") {
   209  				path := params.ByName(p[1:])
   210  				path = strings.TrimPrefix(path, "/")
   211  
   212  				// Split the path into components before passing them to
   213  				// url.PathEscape. Otherwise url.PathEscape will encode "/" into
   214  				// "%2F" because it escapes all non-safe characters in a path
   215  				// component (it should be renamed to url.PathComponentEscape).
   216  				components = append(components, strings.Split(path, "/")...)
   217  			} else {
   218  				components = append(components, p)
   219  			}
   220  		}
   221  
   222  		// Escape the path components ourselves.
   223  		// url.URL.String() should not be used because it escapes everything
   224  		// automatically except '/' making it impossible to have %2F (encoded
   225  		// '/') in a path component ('%2F' will be double encoded to '%252F'
   226  		// while '/' won't be encoded at all).
   227  		for i, p := range components {
   228  			components[i] = url.PathEscape(p)
   229  		}
   230  		return strings.Join(components, "/")
   231  	}
   232  }
   233  
   234  // movedPermanently is a special instance of redirect, returning a handler
   235  // that responds with HTTP 301 (Moved Permanently) with a location specified
   236  // by the pathTemplate.
   237  //
   238  // TODO(nodir,iannucci): delete all usages.
   239  func movedPermanently(pathTemplate string) router.Handler {
   240  	return redirect(pathTemplate, http.StatusMovedPermanently)
   241  }
   242  
   243  func redirectFromProjectlessBuilder(c *router.Context) {
   244  	bucket := c.Params.ByName("bucket")
   245  	builder := c.Params.ByName("builder")
   246  
   247  	project, _ := bbv1.BucketNameToV2(bucket)
   248  	u := *c.Request.URL
   249  	u.Path = fmt.Sprintf("/p/%s/builders/%s/%s", project, bucket, builder)
   250  	http.Redirect(c.Writer, c.Request, u.String(), http.StatusMovedPermanently)
   251  }