[milo] support viewing more /ui/... routes without the /ui/ prefix.
1. implement createInterpolator to support more flexible redirect path
templates.
2. Support redirecting /inv/... to /ui/inv/...
3. Support redirecting /artifact/... to /ui/artifact/...
4. Add unit tests for createInterpolator.
R=chanli, nodir
Change-Id: I4e8b4b930fcee5b4c414e8f1d7b396ad8713755c
Bug: 1189310
Reviewed-on: https://chromium-review.googlesource.com/c/infra/luci/luci-go/+/2812659
Commit-Queue: Weiwei Lin <weiweilin@google.com>
Reviewed-by: Nodir Turakulov <nodir@chromium.org>
diff --git a/milo/frontend/resultui/assets/redirect-sw.js b/milo/frontend/resultui/assets/redirect-sw.js
index 8ff7be4..8e43cad 100644
--- a/milo/frontend/resultui/assets/redirect-sw.js
+++ b/milo/frontend/resultui/assets/redirect-sw.js
@@ -14,7 +14,7 @@
/**
* @fileoverview
- * This service worker redirects old build page links to the new build page.
+ * This service worker redirects milo links to ResultUI links.
* This is 200-400ms faster than redirecting on the server side.
*/
@@ -25,12 +25,18 @@
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
- const isOldBuildPage =
+
+ const isResultUI =
// Short build link.
url.pathname.match(/^\/b\//) ||
// Long build link.
- url.pathname.match(/^\/p\/[^/]+\/builders\/[^/]+\/[^/]+\//);
- if (isOldBuildPage) {
+ url.pathname.match(/^\/p\/[^/]+\/builders\/[^/]+\/[^/]+\//) ||
+ // Invocation link.
+ url.pathname.match(/^\/inv\//) ||
+ // Artifact link.
+ url.pathname.match(/^\/artifact\//);
+
+ if (isResultUI) {
url.pathname = '/ui' + url.pathname;
event.respondWith(Response.redirect(url.toString()));
return;
diff --git a/milo/frontend/routes.go b/milo/frontend/routes.go
index ba1cfe8..b2837a2 100644
--- a/milo/frontend/routes.go
+++ b/milo/frontend/routes.go
@@ -19,9 +19,11 @@
"fmt"
"html/template"
"net/http"
+ "net/url"
"strings"
"time"
+ "github.com/julienschmidt/httprouter"
"go.chromium.org/luci/appengine/gaeauth/server"
"go.chromium.org/luci/appengine/gaemiddleware"
"go.chromium.org/luci/appengine/gaemiddleware/standard"
@@ -79,6 +81,12 @@
r.GET("/internal/cron/update-config", cronMW, UpdateConfigHandler)
r.GET("/internal/cron/update-pools", cronMW, cronHandler(buildbucket.UpdatePools))
+ // Artifacts.
+ r.GET("/artifact/:view_type/*_artifact_name", baseMW, redirect("/ui/artifact/:view_type/*_artifact_name", 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"))
@@ -189,16 +197,63 @@
panic("pathTemplate must start with /")
}
+ interpolator := createInterpolator(pathTemplate)
return func(c *router.Context) {
- parts := strings.Split(pathTemplate, "/")
- for i, p := range parts {
+ 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, ":") {
- parts[i] = c.Params.ByName(p[1:])
+ 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)
}
}
- u := *c.Request.URL
- u.Path = strings.Join(parts, "/")
- http.Redirect(c.Writer, c.Request, u.String(), status)
+
+ // 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, "/")
}
}
diff --git a/milo/frontend/routes_test.go b/milo/frontend/routes_test.go
index 11e55ad..cc53334 100644
--- a/milo/frontend/routes_test.go
+++ b/milo/frontend/routes_test.go
@@ -32,6 +32,7 @@
"github.com/golang/protobuf/jsonpb"
"github.com/golang/protobuf/ptypes"
"github.com/golang/protobuf/ptypes/timestamp"
+ "github.com/julienschmidt/httprouter"
"go.chromium.org/luci/auth/identity"
buildbucketpb "go.chromium.org/luci/buildbucket/proto"
@@ -495,3 +496,31 @@
result := &buildbucketpb.Build{}
return result, jsonpb.Unmarshal(f, result)
}
+
+func TestCreateInterpolator(t *testing.T) {
+ Convey("Test createInterpolator", t, func() {
+ Convey("Should encode params", func() {
+ params := httprouter.Params{httprouter.Param{Key: "component2", Value: ":? +"}}
+ interpolator := createInterpolator("/component1/:component2")
+
+ path := interpolator(params)
+ So(path, ShouldEqual, "/component1/"+url.PathEscape(":? +"))
+ })
+
+ Convey("Should support catching path segments with *", func() {
+ params := httprouter.Params{httprouter.Param{Key: "component2", Value: "/:?/ +"}}
+ interpolator := createInterpolator("/component1/*component2")
+
+ path := interpolator(params)
+ So(path, ShouldEqual, "/component1/"+url.PathEscape(":?")+"/"+url.PathEscape(" +"))
+ })
+
+ Convey("Should support encoding / with *_", func() {
+ params := httprouter.Params{httprouter.Param{Key: "_component2", Value: "/:?/ +"}}
+ interpolator := createInterpolator("/component1/*_component2")
+
+ path := interpolator(params)
+ So(path, ShouldEqual, "/component1/"+url.PathEscape(":?/ +"))
+ })
+ })
+}