| // Copyright 2023 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 |
| |
| import ( |
| "context" |
| "embed" |
| "errors" |
| "fmt" |
| "html/template" |
| "io/fs" |
| "net/http" |
| "time" |
| |
| "github.com/dustin/go-humanize" |
| |
| "go.chromium.org/luci/auth/identity" |
| "go.chromium.org/luci/common/clock" |
| cfgcommonpb "go.chromium.org/luci/common/proto/config" |
| "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" |
| bootstrap "go.chromium.org/luci/web/third_party/bootstrap/v5" |
| |
| "go.chromium.org/luci/config_service/internal/importer" |
| configpb "go.chromium.org/luci/config_service/proto" |
| ) |
| |
| //go:embed pages includes |
| var templateFS embed.FS |
| |
| //go:embed static |
| var staticFS embed.FS |
| |
| var pageStartTimeContextKey = "start time for the frontend page" |
| var configsServerContextKey = "configsServer for the front page" |
| |
| // InstallHandlers adds HTTP handlers that render HTML pages. |
| func InstallHandlers(srv *server.Server, configsSrv configpb.ConfigsServer, importer importer.Importer) { |
| m := router.NewMiddlewareChain( |
| func(c *router.Context, next router.Handler) { |
| reqCtx := c.Request.Context() |
| reqCtx = context.WithValue(reqCtx, &pageStartTimeContextKey, clock.Now(reqCtx)) |
| reqCtx = context.WithValue(reqCtx, &configsServerContextKey, configsSrv) |
| c.Request = c.Request.WithContext(reqCtx) |
| next(c) |
| }, |
| templates.WithTemplates(prepareTemplates(&srv.Options)), |
| auth.Authenticate(srv.CookieAuth), |
| ) |
| switch sFS, err := fs.Sub(staticFS, "static"); { |
| case err != nil: |
| panic(fmt.Errorf("failed to return a subFS for static directory: %w", err)) |
| default: |
| // staticFS has "static" directory at top level. We need to make the child |
| // directories inside the "static" directory top level directory. |
| srv.Routes.Static("/static", nil, http.FS(sFS)) |
| } |
| srv.Routes.Static("/third_party/bootstrap", nil, http.FS(bootstrap.FS)) |
| |
| srv.Routes.GET("/", m, renderErr(indexPage)) |
| srv.Routes.GET("/config_set/*ConfigSet", m, renderErr(configSetPage)) |
| srv.Routes.POST("/internal/frontend/reimport/*ConfigSet", m, importer.Reimport) |
| } |
| |
| // prepareTemplates configures templates.Bundle used by all UI handlers. |
| // |
| // In particular it includes a set of default arguments passed to all templates. |
| func prepareTemplates(opts *server.Options) *templates.Bundle { |
| return &templates.Bundle{ |
| Loader: templates.FileSystemLoader(templateFS), |
| DebugMode: func(context.Context) bool { return !opts.Prod }, |
| DefaultTemplate: "base", |
| FuncMap: template.FuncMap{ |
| "RelTime": func(ts, now time.Time) string { |
| return humanize.RelTime(ts, now, "ago", "from now") |
| }, |
| "HumanizeBytes": func(size int64) string { |
| return humanize.Bytes(uint64(size)) |
| }, |
| "AttemptStatus": attemptStatus, |
| }, |
| DefaultArgs: func(ctx context.Context, e *templates.Extra) (templates.Args, error) { |
| loginURL, err := auth.LoginURL(ctx, e.Request.URL.RequestURI()) |
| if err != nil { |
| return nil, err |
| } |
| 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{ |
| "ImageVersion": opts.ImageVersion(), |
| "IsAnonymous": auth.CurrentIdentity(ctx) == identity.AnonymousIdentity, |
| "User": auth.CurrentUser(ctx), |
| "LoginURL": loginURL, |
| "LogoutURL": logoutURL, |
| "XsrfToken": token, |
| "Now": startTime(ctx), |
| "HandlerDuration": func() time.Duration { |
| return clock.Now(ctx).Sub(startTime(ctx)) |
| }, |
| }, nil |
| }, |
| } |
| } |
| |
| func startTime(c context.Context) time.Time { |
| ts, ok := c.Value(&pageStartTimeContextKey).(time.Time) |
| if !ok { |
| panic(errors.New("impossible; pageStartTimeContextKey is not set")) |
| } |
| return ts |
| } |
| |
| func configsServer(c context.Context) configpb.ConfigsServer { |
| return c.Value(&configsServerContextKey).(configpb.ConfigsServer) |
| } |
| |
| func attemptStatus(attempt *configpb.ConfigSet_Attempt) string { |
| if !attempt.Success { |
| return "failed" |
| } |
| for _, msg := range attempt.GetValidationResult().GetMessages() { |
| if msg.GetSeverity() == cfgcommonpb.ValidationResult_WARNING { |
| return "warning" |
| } |
| } |
| return "success" |
| } |