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  }