blob: 1ae2035ec1d9f636b2e63578510c88b919b4dd4e [file] [log] [blame]
// Copyright 2022 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package ui contains implementation of Web UI handlers.
package ui
import (
"context"
"net/http"
"os"
"strings"
"unicode"
"unicode/utf8"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"go.chromium.org/luci/auth/identity"
"go.chromium.org/luci/grpc/grpcutil"
"go.chromium.org/luci/server"
"go.chromium.org/luci/server/auth"
"go.chromium.org/luci/server/auth/xsrf"
"go.chromium.org/luci/server/router"
"go.chromium.org/luci/server/templates"
"go.chromium.org/luci/deploy/service/rpcs"
)
// UI hosts UI request handlers.
type UI struct {
prod bool // true when running on GAE
version string // e.g. "434535-abcdef"
assets *rpcs.Assets
}
// RegisterRoutes installs UI HTTP routes.
func RegisterRoutes(srv *server.Server, accessGroup string, assets *rpcs.Assets) {
if !srv.Options.Prod {
srv.Routes.Static("/static", nil, http.Dir("./static"))
}
ui := UI{
prod: srv.Options.Prod,
version: srv.Options.ImageVersion(),
assets: assets,
}
mw := router.NewMiddlewareChain(
templates.WithTemplates(ui.prepareTemplates()),
auth.Authenticate(srv.CookieAuth),
checkAccess(accessGroup),
)
srv.Routes.GET("/", mw, wrapErr(ui.indexPage))
// Help the router to route based on the suffix:
//
// /a/<AssetID> the asset page
// /a/<AssetID>/history the history listing page
// /a/<AssetID>/history/<ID> a single history entry
//
// Note that <AssetID> contains unknown number of path components.
srv.Routes.GET("/a/*Path", mw, wrapErr(func(ctx *router.Context) error {
path := strings.TrimPrefix(ctx.Params.ByName("Path"), "/")
chunks := strings.Split(path, "/")
l := len(chunks)
if l > 1 && chunks[l-1] == "history" {
assetID := strings.Join(chunks[:l-1], "/")
return ui.historyListingPage(ctx, assetID)
}
if l > 2 && chunks[l-2] == "history" {
assetID := strings.Join(chunks[:l-2], "/")
historyID := chunks[l-1]
return ui.historyEntryPage(ctx, assetID, historyID)
}
return ui.assetPage(ctx, path)
}))
}
// prepareTemplates loads HTML page templates.
func (ui *UI) prepareTemplates() *templates.Bundle {
return &templates.Bundle{
Loader: templates.FileSystemLoader(os.DirFS("templates")),
DebugMode: func(context.Context) bool { return !ui.prod },
DefaultTemplate: "base",
DefaultArgs: func(ctx context.Context, e *templates.Extra) (templates.Args, error) {
logoutURL, err := auth.LogoutURL(ctx, e.Request.URL.RequestURI())
if err != nil {
return nil, err
}
token, err := xsrf.Token(ctx)
if err != nil {
return nil, err
}
return templates.Args{
"AppVersion": ui.version,
"LogoutURL": logoutURL,
"User": auth.CurrentUser(ctx),
"XsrfToken": token,
}, nil
},
}
}
// checkAccess checks users are authorized to see the UI.
//
// Redirect anonymous users to the login page.
func checkAccess(accessGroup string) router.Middleware {
return func(ctx *router.Context, next router.Handler) {
// Redirect anonymous users to login first.
if auth.CurrentIdentity(ctx.Request.Context()) == identity.AnonymousIdentity {
loginURL, err := auth.LoginURL(ctx.Request.Context(), ctx.Request.URL.RequestURI())
if err != nil {
replyErr(ctx, err)
} else {
http.Redirect(ctx.Writer, ctx.Request, loginURL, http.StatusFound)
}
return
}
// Check they are in the access group.
switch yes, err := auth.IsMember(ctx.Request.Context(), accessGroup); {
case err != nil:
replyErr(ctx, err)
case !yes:
replyErr(ctx, status.Errorf(codes.PermissionDenied,
"Access denied. Not a member of %q group. Try to login with a different email.",
accessGroup))
default:
next(ctx)
}
}
}
// wrapErr is a handler wrapper that converts gRPC errors into HTML pages.
func wrapErr(h func(*router.Context) error) router.Handler {
return func(ctx *router.Context) {
if err := h(ctx); err != nil {
replyErr(ctx, err)
}
}
}
// replyErr renders an HTML page with an error message.
func replyErr(ctx *router.Context, err error) {
s, _ := status.FromError(err)
message := s.Message()
if message != "" {
// Convert the first rune to upper case.
r, n := utf8.DecodeRuneInString(message)
message = string(unicode.ToUpper(r)) + message[n:]
} else {
message = "Unspecified error" // this should not really happen
}
ctx.Writer.Header().Set("Content-Type", "text/html; charset=utf-8")
ctx.Writer.WriteHeader(grpcutil.CodeStatus(s.Code()))
templates.MustRender(ctx.Request.Context(), ctx.Writer, "pages/error.html", map[string]any{
"Message": message,
})
}