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 }