go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/deploy/service/ui/common.go (about) 1 // Copyright 2022 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 ui contains implementation of Web UI handlers. 16 package ui 17 18 import ( 19 "context" 20 "net/http" 21 "os" 22 "strings" 23 "unicode" 24 "unicode/utf8" 25 26 "google.golang.org/grpc/codes" 27 "google.golang.org/grpc/status" 28 29 "go.chromium.org/luci/auth/identity" 30 "go.chromium.org/luci/grpc/grpcutil" 31 "go.chromium.org/luci/server" 32 "go.chromium.org/luci/server/auth" 33 "go.chromium.org/luci/server/auth/xsrf" 34 "go.chromium.org/luci/server/router" 35 "go.chromium.org/luci/server/templates" 36 37 "go.chromium.org/luci/deploy/service/rpcs" 38 ) 39 40 // UI hosts UI request handlers. 41 type UI struct { 42 prod bool // true when running on GAE 43 version string // e.g. "434535-abcdef" 44 45 assets *rpcs.Assets 46 } 47 48 // RegisterRoutes installs UI HTTP routes. 49 func RegisterRoutes(srv *server.Server, accessGroup string, assets *rpcs.Assets) { 50 if !srv.Options.Prod { 51 srv.Routes.Static("/static", nil, http.Dir("./static")) 52 } 53 54 ui := UI{ 55 prod: srv.Options.Prod, 56 version: srv.Options.ImageVersion(), 57 assets: assets, 58 } 59 60 mw := router.NewMiddlewareChain( 61 templates.WithTemplates(ui.prepareTemplates()), 62 auth.Authenticate(srv.CookieAuth), 63 checkAccess(accessGroup), 64 ) 65 66 srv.Routes.GET("/", mw, wrapErr(ui.indexPage)) 67 68 // Help the router to route based on the suffix: 69 // 70 // /a/<AssetID> the asset page 71 // /a/<AssetID>/history the history listing page 72 // /a/<AssetID>/history/<ID> a single history entry 73 // 74 // Note that <AssetID> contains unknown number of path components. 75 srv.Routes.GET("/a/*Path", mw, wrapErr(func(ctx *router.Context) error { 76 path := strings.TrimPrefix(ctx.Params.ByName("Path"), "/") 77 chunks := strings.Split(path, "/") 78 l := len(chunks) 79 80 if l > 1 && chunks[l-1] == "history" { 81 assetID := strings.Join(chunks[:l-1], "/") 82 return ui.historyListingPage(ctx, assetID) 83 } 84 85 if l > 2 && chunks[l-2] == "history" { 86 assetID := strings.Join(chunks[:l-2], "/") 87 historyID := chunks[l-1] 88 return ui.historyEntryPage(ctx, assetID, historyID) 89 } 90 91 return ui.assetPage(ctx, path) 92 })) 93 } 94 95 // prepareTemplates loads HTML page templates. 96 func (ui *UI) prepareTemplates() *templates.Bundle { 97 return &templates.Bundle{ 98 Loader: templates.FileSystemLoader(os.DirFS("templates")), 99 DebugMode: func(context.Context) bool { return !ui.prod }, 100 DefaultTemplate: "base", 101 DefaultArgs: func(ctx context.Context, e *templates.Extra) (templates.Args, error) { 102 logoutURL, err := auth.LogoutURL(ctx, e.Request.URL.RequestURI()) 103 if err != nil { 104 return nil, err 105 } 106 token, err := xsrf.Token(ctx) 107 if err != nil { 108 return nil, err 109 } 110 return templates.Args{ 111 "AppVersion": ui.version, 112 "LogoutURL": logoutURL, 113 "User": auth.CurrentUser(ctx), 114 "XsrfToken": token, 115 }, nil 116 }, 117 } 118 } 119 120 // checkAccess checks users are authorized to see the UI. 121 // 122 // Redirect anonymous users to the login page. 123 func checkAccess(accessGroup string) router.Middleware { 124 return func(ctx *router.Context, next router.Handler) { 125 // Redirect anonymous users to login first. 126 if auth.CurrentIdentity(ctx.Request.Context()) == identity.AnonymousIdentity { 127 loginURL, err := auth.LoginURL(ctx.Request.Context(), ctx.Request.URL.RequestURI()) 128 if err != nil { 129 replyErr(ctx, err) 130 } else { 131 http.Redirect(ctx.Writer, ctx.Request, loginURL, http.StatusFound) 132 } 133 return 134 } 135 // Check they are in the access group. 136 switch yes, err := auth.IsMember(ctx.Request.Context(), accessGroup); { 137 case err != nil: 138 replyErr(ctx, err) 139 case !yes: 140 replyErr(ctx, status.Errorf(codes.PermissionDenied, 141 "Access denied. Not a member of %q group. Try to login with a different email.", 142 accessGroup)) 143 default: 144 next(ctx) 145 } 146 } 147 } 148 149 // wrapErr is a handler wrapper that converts gRPC errors into HTML pages. 150 func wrapErr(h func(*router.Context) error) router.Handler { 151 return func(ctx *router.Context) { 152 if err := h(ctx); err != nil { 153 replyErr(ctx, err) 154 } 155 } 156 } 157 158 // replyErr renders an HTML page with an error message. 159 func replyErr(ctx *router.Context, err error) { 160 s, _ := status.FromError(err) 161 message := s.Message() 162 if message != "" { 163 // Convert the first rune to upper case. 164 r, n := utf8.DecodeRuneInString(message) 165 message = string(unicode.ToUpper(r)) + message[n:] 166 } else { 167 message = "Unspecified error" // this should not really happen 168 } 169 170 ctx.Writer.Header().Set("Content-Type", "text/html; charset=utf-8") 171 ctx.Writer.WriteHeader(grpcutil.CodeStatus(s.Code())) 172 templates.MustRender(ctx.Request.Context(), ctx.Writer, "pages/error.html", map[string]any{ 173 "Message": message, 174 }) 175 }