feat: embed common client requests into the template html (#8076)

This should reduce the number of API requests a client makes
when loading the dashboard dramatically!
This commit is contained in:
Kyle Carberry
2023-06-18 13:57:27 -05:00
committed by GitHub
parent 2a10c9127f
commit 9df9ad4503
15 changed files with 409 additions and 184 deletions
+2 -1
View File
@@ -17,6 +17,7 @@ import (
"cdr.dev/slog"
"github.com/coder/coder/coderd/audit"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/db2sdk"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/rbac"
@@ -193,7 +194,7 @@ func (api *API) convertAuditLog(ctx context.Context, dblog database.GetAuditLogs
for _, roleName := range dblog.UserRoles {
rbacRole, _ := rbac.RoleByName(roleName)
user.Roles = append(user.Roles, convertRole(rbacRole))
user.Roles = append(user.Roles, db2sdk.Role(rbacRole))
}
}
+15 -8
View File
@@ -293,11 +293,13 @@ func New(options *Options) *API {
},
)
staticHandler := site.Handler(site.FS(), binFS, binHashes)
// Static file handler must be wrapped with HSTS handler if the
// StrictTransportSecurityAge is set. We only need to set this header on
// static files since it only affects browsers.
staticHandler = httpmw.HSTS(staticHandler, options.StrictTransportSecurityCfg)
staticHandler := site.New(&site.Options{
BinFS: binFS,
BinHashes: binHashes,
Database: options.Database,
SiteFS: site.FS(),
})
staticHandler.Experiments.Store(&experiments)
oauthConfigs := &httpmw.OAuth2Configs{
Github: options.GithubOAuth2Config,
@@ -313,7 +315,7 @@ func New(options *Options) *API {
ID: uuid.New(),
Options: options,
RootHandler: r,
siteHandler: staticHandler,
SiteHandler: staticHandler,
HTTPAuth: &HTTPAuthorizer{
Authorizer: options.Authorizer,
Logger: options.Logger,
@@ -813,7 +815,11 @@ func New(options *Options) *API {
// By default we do not add extra websocket connections to the CSP
return []string{}
})
r.NotFound(cspMW(compressHandler(http.HandlerFunc(api.siteHandler.ServeHTTP))).ServeHTTP)
// Static file handler must be wrapped with HSTS handler if the
// StrictTransportSecurityAge is set. We only need to set this header on
// static files since it only affects browsers.
r.NotFound(cspMW(compressHandler(httpmw.HSTS(api.SiteHandler, options.StrictTransportSecurityCfg))).ServeHTTP)
// This must be before all middleware to improve the response time.
// So make a new router, and mount the old one as the root.
@@ -858,7 +864,8 @@ type API struct {
// RootHandler serves "/"
RootHandler chi.Router
siteHandler http.Handler
// SiteHandler serves static files for the dashboard.
SiteHandler *site.Handler
WebsocketWaitMutex sync.Mutex
WebsocketWaitGroup sync.WaitGroup
+31
View File
@@ -5,8 +5,11 @@ import (
"encoding/json"
"time"
"github.com/google/uuid"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/parameter"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/provisionersdk/proto"
)
@@ -100,3 +103,31 @@ func ProvisionerJobStatus(provisionerJob database.ProvisionerJob) codersdk.Provi
return codersdk.ProvisionerJobRunning
}
}
func User(user database.User, organizationIDs []uuid.UUID) codersdk.User {
convertedUser := codersdk.User{
ID: user.ID,
Email: user.Email,
CreatedAt: user.CreatedAt,
LastSeenAt: user.LastSeenAt,
Username: user.Username,
Status: codersdk.UserStatus(user.Status),
OrganizationIDs: organizationIDs,
Roles: make([]codersdk.Role, 0, len(user.RBACRoles)),
AvatarURL: user.AvatarURL.String,
}
for _, roleName := range user.RBACRoles {
rbacRole, _ := rbac.RoleByName(roleName)
convertedUser.Roles = append(convertedUser.Roles, Role(rbacRole))
}
return convertedUser
}
func Role(role rbac.Role) codersdk.Role {
return codersdk.Role{
DisplayName: role.DisplayName,
Name: role.Name,
}
}
+2 -1
View File
@@ -8,6 +8,7 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/database/db2sdk"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/database"
@@ -104,7 +105,7 @@ func convertOrganizationMember(mem database.OrganizationMember) codersdk.Organiz
for _, roleName := range mem.Roles {
rbacRole, _ := rbac.RoleByName(roleName)
convertedMember.Roles = append(convertedMember.Roles, convertRole(rbacRole))
convertedMember.Roles = append(convertedMember.Roles, db2sdk.Role(rbacRole))
}
return convertedMember
}
-7
View File
@@ -55,13 +55,6 @@ func (api *API) assignableOrgRoles(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, assignableRoles(actorRoles.Actor.Roles, roles))
}
func convertRole(role rbac.Role) codersdk.Role {
return codersdk.Role{
DisplayName: role.DisplayName,
Name: role.Name,
}
}
func assignableRoles(actorRoles rbac.ExpandableRoles, roles []rbac.Role) []codersdk.AssignableRoles {
assignable := make([]codersdk.AssignableRoles, 0)
for _, role := range roles {
+7 -27
View File
@@ -14,6 +14,7 @@ import (
"github.com/coder/coder/coderd/audit"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/db2sdk"
"github.com/coder/coder/coderd/database/dbauthz"
"github.com/coder/coder/coderd/gitsshkey"
"github.com/coder/coder/coderd/httpapi"
@@ -401,7 +402,7 @@ func (api *API) postUser(rw http.ResponseWriter, r *http.Request) {
Users: []telemetry.User{telemetry.ConvertUser(user)},
})
httpapi.Write(ctx, rw, http.StatusCreated, convertUser(user, []uuid.UUID{req.OrganizationID}))
httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.User(user, []uuid.UUID{req.OrganizationID}))
}
// @Summary Delete user
@@ -495,7 +496,7 @@ func (api *API) userByName(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusOK, convertUser(user, organizationIDs))
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(user, organizationIDs))
}
// @Summary Update user profile
@@ -580,7 +581,7 @@ func (api *API) putUserProfile(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusOK, convertUser(updatedUserProfile, organizationIDs))
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(updatedUserProfile, organizationIDs))
}
// @Summary Suspend user account
@@ -667,7 +668,7 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW
return
}
httpapi.Write(ctx, rw, http.StatusOK, convertUser(suspendedUser, organizations))
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(suspendedUser, organizations))
}
}
@@ -892,7 +893,7 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) {
return
}
httpapi.Write(ctx, rw, http.StatusOK, convertUser(updatedUser, organizationIDs))
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(updatedUser, organizationIDs))
}
// updateSiteUserRoles will ensure only site wide roles are passed in as arguments.
@@ -1087,32 +1088,11 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create
}, nil)
}
func convertUser(user database.User, organizationIDs []uuid.UUID) codersdk.User {
convertedUser := codersdk.User{
ID: user.ID,
Email: user.Email,
CreatedAt: user.CreatedAt,
LastSeenAt: user.LastSeenAt,
Username: user.Username,
Status: codersdk.UserStatus(user.Status),
OrganizationIDs: organizationIDs,
Roles: make([]codersdk.Role, 0, len(user.RBACRoles)),
AvatarURL: user.AvatarURL.String,
}
for _, roleName := range user.RBACRoles {
rbacRole, _ := rbac.RoleByName(roleName)
convertedUser.Roles = append(convertedUser.Roles, convertRole(rbacRole))
}
return convertedUser
}
func convertUsers(users []database.User, organizationIDsByUserID map[uuid.UUID][]uuid.UUID) []codersdk.User {
converted := make([]codersdk.User, 0, len(users))
for _, u := range users {
userOrganizationIDs := organizationIDsByUserID[u.ID]
converted = append(converted, convertUser(u, userOrganizationIDs))
converted = append(converted, db2sdk.User(u, userOrganizationIDs))
}
return converted
}
+41 -28
View File
@@ -1,6 +1,7 @@
package coderd
import (
"context"
"database/sql"
"encoding/hex"
"encoding/json"
@@ -8,6 +9,7 @@ import (
"fmt"
"net/http"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"github.com/coder/coder/coderd/httpapi"
@@ -41,35 +43,49 @@ var DefaultSupportLinks = []codersdk.LinkConfig{
// @Success 200 {object} codersdk.AppearanceConfig
// @Router /appearance [get]
func (api *API) appearance(rw http.ResponseWriter, r *http.Request) {
cfg, err := api.fetchAppearanceConfig(r.Context())
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to fetch appearance config.",
Detail: err.Error(),
})
return
}
httpapi.Write(r.Context(), rw, http.StatusOK, cfg)
}
func (api *API) fetchAppearanceConfig(ctx context.Context) (codersdk.AppearanceConfig, error) {
api.entitlementsMu.RLock()
isEntitled := api.entitlements.Features[codersdk.FeatureAppearance].Entitlement == codersdk.EntitlementEntitled
api.entitlementsMu.RUnlock()
ctx := r.Context()
if !isEntitled {
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AppearanceConfig{
return codersdk.AppearanceConfig{
SupportLinks: DefaultSupportLinks,
})
return
}, nil
}
logoURL, err := api.Database.GetLogoURL(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to fetch logo URL.",
Detail: err.Error(),
})
return
}
serviceBannerJSON, err := api.Database.GetServiceBanner(r.Context())
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to fetch service banner.",
Detail: err.Error(),
})
return
var eg errgroup.Group
var logoURL string
var serviceBannerJSON string
eg.Go(func() (err error) {
logoURL, err = api.Database.GetLogoURL(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("get logo url: %w", err)
}
return nil
})
eg.Go(func() (err error) {
serviceBannerJSON, err = api.Database.GetServiceBanner(ctx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("get service banner: %w", err)
}
return nil
})
err := eg.Wait()
if err != nil {
return codersdk.AppearanceConfig{}, err
}
cfg := codersdk.AppearanceConfig{
@@ -78,12 +94,9 @@ func (api *API) appearance(rw http.ResponseWriter, r *http.Request) {
if serviceBannerJSON != "" {
err = json.Unmarshal([]byte(serviceBannerJSON), &cfg.ServiceBanner)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf(
"unmarshal json: %+v, raw: %s", err, serviceBannerJSON,
),
})
return
return codersdk.AppearanceConfig{}, xerrors.Errorf(
"unmarshal json: %w, raw: %s", err, serviceBannerJSON,
)
}
}
@@ -93,7 +106,7 @@ func (api *API) appearance(rw http.ResponseWriter, r *http.Request) {
cfg.SupportLinks = api.DeploymentValues.Support.Links.Value
}
httpapi.Write(r.Context(), rw, http.StatusOK, cfg)
return cfg, nil
}
func validateHexColor(color string) error {
+2
View File
@@ -66,6 +66,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
}()
api.AGPL.Options.SetUserGroups = api.setUserGroups
api.AGPL.SiteHandler.AppearanceFetcher = api.fetchAppearanceConfig
oauthConfigs := &httpmw.OAuth2Configs{
Github: options.GithubOAuth2Config,
@@ -451,6 +452,7 @@ func (api *API) updateEntitlements(ctx context.Context) error {
}
api.entitlements = entitlements
api.AGPL.SiteHandler.Entitlements.Store(&entitlements)
return nil
}
+5 -2
View File
@@ -12,11 +12,14 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#17172E" />
<meta name="application-name" content="Coder2" />
<meta name="application-name" content="Coder" />
<meta property="og:type" content="website" />
<meta property="csp-nonce" content="{{ .CSP.Nonce }}" />
<meta property="csrf-token" content="{{ .CSRF.Token }}" />
<meta property="build-info" content="{{ .BuildInfo }}" />
<meta property="user" content="{{ .User }}" />
<meta property="entitlements" content="{{ .Entitlements }}" />
<meta property="appearance" content="{{ .Appearance }}" />
<meta property="experiments" content="{{ .Experiments }}" />
<!-- We need to set data-react-helmet to be able to override it in the workspace page -->
<link
rel="alternate icon"
+185 -103
View File
@@ -3,7 +3,9 @@ package site
import (
"archive/tar"
"bytes"
"context"
"crypto/sha1" //#nosec // Not used for cryptography.
"database/sql"
_ "embed"
"encoding/hex"
"encoding/json"
@@ -19,9 +21,11 @@ import (
"path/filepath"
"strings"
"sync"
"sync/atomic"
"text/template" // html/template escapes some nonces
"time"
"github.com/google/uuid"
"github.com/justinas/nosurf"
"github.com/klauspost/compress/zstd"
"github.com/unrolled/secure"
@@ -31,7 +35,11 @@ import (
"golang.org/x/xerrors"
"github.com/coder/coder/buildinfo"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/db2sdk"
"github.com/coder/coder/coderd/database/dbauthz"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/codersdk"
)
@@ -52,19 +60,28 @@ func init() {
}
}
// Handler returns an HTTP handler for serving the static site.
func Handler(siteFS fs.FS, binFS http.FileSystem, binHashes map[string]string) http.Handler {
// html files are handled by a text/template. Non-html files
// are served by the default file server.
//
// REMARK: text/template is needed to inject values on each request like
// CSRF.
files, err := htmlFiles(siteFS)
if err != nil {
panic(xerrors.Errorf("Failed to return handler for static files. Html files failed to load: %w", err))
type Options struct {
BinFS http.FileSystem
BinHashes map[string]string
Database database.Store
SiteFS fs.FS
}
func New(opts *Options) *Handler {
handler := &Handler{
opts: opts,
secureHeaders: secureHeaders(),
}
binHashCache := newBinHashCache(binFS, binHashes)
// html files are handled by a text/template. Non-html files
// are served by the default file server.
var err error
handler.htmlTemplates, err = findAndParseHTMLFiles(opts.SiteFS)
if err != nil {
panic(fmt.Sprintf("Failed to parse html files: %v", err))
}
binHashCache := newBinHashCache(opts.BinFS, opts.BinHashes)
mux := http.NewServeMux()
mux.Handle("/bin/", http.StripPrefix("/bin", http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
@@ -77,7 +94,7 @@ func Handler(siteFS fs.FS, binFS http.FileSystem, binHashes map[string]string) h
name := filePath(r.URL.Path)
if name == "" || name == "/" {
// Serve the directory listing.
http.FileServer(binFS).ServeHTTP(rw, r)
http.FileServer(opts.BinFS).ServeHTTP(rw, r)
return
}
if strings.Contains(name, "/") {
@@ -101,9 +118,9 @@ func Handler(siteFS fs.FS, binFS http.FileSystem, binHashes map[string]string) h
// http.FileServer will see the ETag header and automatically handle
// If-Match and If-None-Match headers on the request properly.
http.FileServer(binFS).ServeHTTP(rw, r)
http.FileServer(opts.BinFS).ServeHTTP(rw, r)
})))
mux.Handle("/", http.FileServer(http.FS(siteFS))) // All other non-html static files.
mux.Handle("/", http.FileServer(http.FS(opts.SiteFS)))
buildInfo := codersdk.BuildInfoResponse{
ExternalURL: buildinfo.ExternalURL(),
@@ -113,21 +130,80 @@ func Handler(siteFS fs.FS, binFS http.FileSystem, binHashes map[string]string) h
if err != nil {
panic("failed to marshal build info: " + err.Error())
}
handler.buildInfoJSON = html.EscapeString(string(buildInfoResponse))
handler.handler = mux.ServeHTTP
return secureHeaders(&handler{
fs: siteFS,
htmlFiles: files,
h: mux,
buildInfoJSON: html.EscapeString(string(buildInfoResponse)),
})
return handler
}
type handler struct {
fs fs.FS
// htmlFiles is the text/template for all *.html files.
htmlFiles *htmlTemplates
h http.Handler
type Handler struct {
opts *Options
secureHeaders *secure.Secure
handler http.HandlerFunc
htmlTemplates *template.Template
buildInfoJSON string
AppearanceFetcher func(ctx context.Context) (codersdk.AppearanceConfig, error)
Entitlements atomic.Pointer[codersdk.Entitlements]
Experiments atomic.Pointer[codersdk.Experiments]
}
func (h *Handler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
err := h.secureHeaders.Process(rw, r)
if err != nil {
return
}
// reqFile is the static file requested
reqFile := filePath(r.URL.Path)
state := htmlState{
// Token is the CSRF token for the given request
CSRF: csrfState{Token: nosurf.Token(r)},
BuildInfo: h.buildInfoJSON,
}
// First check if it's a file we have in our templates
if h.serveHTML(rw, r, reqFile, state) {
return
}
switch {
// If requesting binaries, serve straight up.
case reqFile == "bin" || strings.HasPrefix(reqFile, "bin/"):
h.handler.ServeHTTP(rw, r)
return
// If the original file path exists we serve it.
case h.exists(reqFile):
if ShouldCacheFile(reqFile) {
rw.Header().Add("Cache-Control", "public, max-age=31536000, immutable")
}
h.handler.ServeHTTP(rw, r)
return
}
// Serve the file assuming it's an html file
// This matches paths like `/app/terminal.html`
r.URL.Path = strings.TrimSuffix(r.URL.Path, "/")
r.URL.Path += ".html"
reqFile = filePath(r.URL.Path)
// All html files should be served by the htmlFile templates
if h.serveHTML(rw, r, reqFile, state) {
return
}
// If we don't have the file... we should redirect to `/`
// for our single-page-app.
r.URL.Path = "/"
if h.serveHTML(rw, r, "", state) {
return
}
// This will send a correct 404
h.handler.ServeHTTP(rw, r)
}
// filePath returns the filepath of the requested file.
@@ -138,8 +214,8 @@ func filePath(p string) string {
return strings.TrimPrefix(path.Clean(p), "/")
}
func (h *handler) exists(filePath string) bool {
f, err := h.fs.Open(filePath)
func (h *Handler) exists(filePath string) bool {
f, err := h.opts.SiteFS.Open(filePath)
if err == nil {
_ = f.Close()
}
@@ -147,8 +223,14 @@ func (h *handler) exists(filePath string) bool {
}
type htmlState struct {
CSRF csrfState
BuildInfo string
CSRF csrfState
// Below are HTML escaped JSON strings of the respective structs.
BuildInfo string
User string
Entitlements string
Appearance string
Experiments string
}
type csrfState struct {
@@ -164,14 +246,7 @@ func ShouldCacheFile(reqFile string) bool {
// techniques are one-offs or things that should have invalidation in the
// future.
denyListedSuffixes := []string{
// ALL *.html files
".html",
// ALL *worker.js files (including service-worker.js)
//
// REMARK(Grey): I'm unsure if there's a desired setting in Workbox for
// content hashing these, or if doing so is a risk for
// users that have a PWA installed.
"worker.js",
}
@@ -184,58 +259,8 @@ func ShouldCacheFile(reqFile string) bool {
return true
}
func (h *handler) ServeHTTP(resp http.ResponseWriter, req *http.Request) {
// reqFile is the static file requested
reqFile := filePath(req.URL.Path)
state := htmlState{
// Token is the CSRF token for the given request
CSRF: csrfState{Token: nosurf.Token(req)},
BuildInfo: h.buildInfoJSON,
}
// First check if it's a file we have in our templates
if h.serveHTML(resp, req, reqFile, state) {
return
}
switch {
// If requesting binaries, serve straight up.
case reqFile == "bin" || strings.HasPrefix(reqFile, "bin/"):
h.h.ServeHTTP(resp, req)
return
// If the original file path exists we serve it.
case h.exists(reqFile):
if ShouldCacheFile(reqFile) {
resp.Header().Add("Cache-Control", "public, max-age=31536000, immutable")
}
h.h.ServeHTTP(resp, req)
return
}
// Serve the file assuming it's an html file
// This matches paths like `/app/terminal.html`
req.URL.Path = strings.TrimSuffix(req.URL.Path, "/")
req.URL.Path += ".html"
reqFile = filePath(req.URL.Path)
// All html files should be served by the htmlFile templates
if h.serveHTML(resp, req, reqFile, state) {
return
}
// If we don't have the file... we should redirect to `/`
// for our single-page-app.
req.URL.Path = "/"
if h.serveHTML(resp, req, "", state) {
return
}
// This will send a correct 404
h.h.ServeHTTP(resp, req)
}
func (h *handler) serveHTML(resp http.ResponseWriter, request *http.Request, reqPath string, state htmlState) bool {
if data, err := h.htmlFiles.renderWithState(reqPath, state); err == nil {
func (h *Handler) serveHTML(resp http.ResponseWriter, request *http.Request, reqPath string, state htmlState) bool {
if data, err := h.renderHTMLWithState(resp, request, reqPath, state); err == nil {
if reqPath == "" {
// Pass "index.html" to the ServeContent so the ServeContent sets the right content headers.
reqPath = "index.html"
@@ -246,28 +271,88 @@ func (h *handler) serveHTML(resp http.ResponseWriter, request *http.Request, req
return false
}
type htmlTemplates struct {
tpls *template.Template
}
// renderWithState will render the file using the given nonce if the file exists
// as a template. If it does not, it will return an error.
func (t *htmlTemplates) renderWithState(filePath string, state htmlState) ([]byte, error) {
func (h *Handler) renderHTMLWithState(rw http.ResponseWriter, r *http.Request, filePath string, state htmlState) ([]byte, error) {
var buf bytes.Buffer
if filePath == "" {
filePath = "index.html"
}
err := t.tpls.ExecuteTemplate(&buf, filePath, state)
tmpl := h.htmlTemplates.Lookup(filePath)
if tmpl == nil {
return nil, xerrors.Errorf("template %q not found", filePath)
}
// Cookies are sent when requesting HTML, so we can get the user
// and pre-populate the state for the frontend to reduce requests.
apiKey, actor, _ := httpmw.ExtractAPIKey(rw, r, httpmw.ExtractAPIKeyConfig{
Optional: true,
DB: h.opts.Database,
})
if apiKey != nil && actor != nil {
ctx := dbauthz.As(r.Context(), actor.Actor)
var eg errgroup.Group
var user database.User
orgIDs := []uuid.UUID{}
eg.Go(func() error {
var err error
user, err = h.opts.Database.GetUserByID(ctx, apiKey.UserID)
return err
})
eg.Go(func() error {
memberIDs, err := h.opts.Database.GetOrganizationIDsByMemberIDs(ctx, []uuid.UUID{apiKey.UserID})
if errors.Is(err, sql.ErrNoRows) || len(memberIDs) == 0 {
return nil
}
if err != nil {
return nil
}
orgIDs = memberIDs[0].OrganizationIDs
return err
})
err := eg.Wait()
if err == nil {
user, err := json.Marshal(db2sdk.User(user, orgIDs))
if err == nil {
state.User = html.EscapeString(string(user))
}
entitlements := h.Entitlements.Load()
if entitlements != nil {
entitlements, err := json.Marshal(entitlements)
if err == nil {
state.Entitlements = html.EscapeString(string(entitlements))
}
}
if h.AppearanceFetcher != nil {
cfg, err := h.AppearanceFetcher(ctx)
if err == nil {
appearance, err := json.Marshal(cfg)
if err == nil {
state.Appearance = html.EscapeString(string(appearance))
}
}
}
experiments := h.Experiments.Load()
if experiments != nil {
experiments, err := json.Marshal(experiments)
if err == nil {
state.Experiments = html.EscapeString(string(experiments))
}
}
}
}
err := tmpl.Execute(&buf, state)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// secureHeaders is only needed for statically served files. We do not need this for api endpoints.
// It adds various headers to enforce browser security features.
func secureHeaders(next http.Handler) http.Handler {
func secureHeaders() *secure.Secure {
// Permissions-Policy can be used to disabled various browser features that we do not use.
// This can prevent an embedded iframe from accessing these features.
// If we support arbitrary iframes such as generic applications, we might need to add permissions
@@ -296,12 +381,12 @@ func secureHeaders(next http.Handler) http.Handler {
// Prevent the browser from sending Referrer header with requests
ReferrerPolicy: "no-referrer",
}).Handler(next)
})
}
// htmlFiles recursively walks the file system passed finding all *.html files.
// findAndParseHTMLFiles recursively walks the file system passed finding all *.html files.
// The template returned has all html files parsed.
func htmlFiles(files fs.FS) (*htmlTemplates, error) {
func findAndParseHTMLFiles(files fs.FS) (*template.Template, error) {
// root is the collection of html templates. All templates are named by their pathing.
// So './404.html' is named '404.html'. './subdir/index.html' is 'subdir/index.html'
root := template.New("")
@@ -341,10 +426,7 @@ func htmlFiles(files fs.FS) (*htmlTemplates, error) {
if err != nil {
return nil, err
}
return &htmlTemplates{
tpls: root,
}, nil
return root, nil
}
// ExtractOrReadBinFS checks the provided fs for compressed coder binaries and
+59 -3
View File
@@ -3,7 +3,9 @@ package site_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"html"
"io"
"io/fs"
"net/http"
@@ -16,13 +18,57 @@ import (
"testing/fstest"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/database/db2sdk"
"github.com/coder/coder/coderd/database/dbfake"
"github.com/coder/coder/coderd/database/dbgen"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/site"
"github.com/coder/coder/testutil"
)
func TestInjection(t *testing.T) {
t.Parallel()
siteFS := fstest.MapFS{
"index.html": &fstest.MapFile{
Data: []byte("{{ .User }}"),
},
}
binFs := http.FS(fstest.MapFS{})
db := dbfake.New()
handler := site.New(&site.Options{
BinFS: binFs,
Database: db,
SiteFS: siteFS,
})
user := dbgen.User(t, db, database.User{})
_, token := dbgen.APIKey(t, db, database.APIKey{
UserID: user.ID,
ExpiresAt: time.Now().Add(time.Hour),
})
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set(codersdk.SessionTokenHeader, token)
rw := httptest.NewRecorder()
handler.ServeHTTP(rw, r)
require.Equal(t, http.StatusOK, rw.Code)
var got codersdk.User
err := json.Unmarshal([]byte(html.UnescapeString(rw.Body.String())), &got)
require.NoError(t, err)
// This will update as part of the request!
got.LastSeenAt = user.LastSeenAt
require.Equal(t, db2sdk.User(user, []uuid.UUID{}), got)
}
func TestCaching(t *testing.T) {
t.Parallel()
@@ -45,7 +91,10 @@ func TestCaching(t *testing.T) {
}
binFS := http.FS(fstest.MapFS{})
srv := httptest.NewServer(site.Handler(rootFS, binFS, nil))
srv := httptest.NewServer(site.New(&site.Options{
BinFS: binFS,
SiteFS: rootFS,
}))
defer srv.Close()
// Create a context
@@ -105,7 +154,10 @@ func TestServingFiles(t *testing.T) {
}
binFS := http.FS(fstest.MapFS{})
srv := httptest.NewServer(site.Handler(rootFS, binFS, nil))
srv := httptest.NewServer(site.New(&site.Options{
BinFS: binFS,
SiteFS: rootFS,
}))
defer srv.Close()
// Create a context
@@ -358,7 +410,11 @@ func TestServingBin(t *testing.T) {
require.Error(t, err, "extraction or read did not fail")
}
srv := httptest.NewServer(site.Handler(rootFS, binFS, binHashes))
srv := httptest.NewServer(site.New(&site.Options{
BinFS: binFS,
BinHashes: binHashes,
SiteFS: rootFS,
}))
defer srv.Close()
// Create a context
@@ -107,7 +107,20 @@ export const appearanceMachine = createMachine(
}),
},
services: {
getAppearance: API.getAppearance,
getAppearance: async () => {
// Appearance is injected by the Coder server into the HTML document.
const appearance = document.querySelector("meta[property=appearance]")
if (appearance) {
const rawContent = appearance.getAttribute("content")
try {
return JSON.parse(rawContent as string)
} catch (ex) {
// Ignore this and fetch as normal!
}
}
return API.getAppearance()
},
setAppearance: (_, event) => API.updateAppearance(event.appearance),
},
},
+16 -1
View File
@@ -99,7 +99,22 @@ export const isAuthenticated = (data?: AuthData): data is AuthenticatedData =>
data !== undefined && "user" in data
const loadInitialAuthData = async (): Promise<AuthData> => {
const authenticatedUser = await API.getAuthenticatedUser()
let authenticatedUser: TypesGen.User | undefined
// User is injected by the Coder server into the HTML document.
const userMeta = document.querySelector("meta[property=user]")
if (userMeta) {
const rawContent = userMeta.getAttribute("content")
try {
authenticatedUser = JSON.parse(rawContent as string) as TypesGen.User
} catch (ex) {
// Ignore this and fetch as normal!
}
}
// If we have the user from the meta tag, we can skip this!
if (!authenticatedUser) {
authenticatedUser = await API.getAuthenticatedUser()
}
if (authenticatedUser) {
const permissions = (await API.checkAuthorization({
@@ -58,7 +58,22 @@ export const entitlementsMachine = createMachine(
}),
},
services: {
getEntitlements: () => API.getEntitlements(),
getEntitlements: async () => {
// Entitlements is injected by the Coder server into the HTML document.
const entitlements = document.querySelector(
"meta[property=entitlements]",
)
if (entitlements) {
const rawContent = entitlements.getAttribute("content")
try {
return JSON.parse(rawContent as string)
} catch (ex) {
// Ignore this and fetch as normal!
}
}
return API.getEntitlements()
},
},
},
)
@@ -50,7 +50,20 @@ export const experimentsMachine = createMachine(
},
{
services: {
getExperiments: getExperiments,
getExperiments: async () => {
// Experiments is injected by the Coder server into the HTML document.
const experiments = document.querySelector("meta[property=experiments]")
if (experiments) {
const rawContent = experiments.getAttribute("content")
try {
return JSON.parse(rawContent as string)
} catch (ex) {
// Ignore this and fetch as normal!
}
}
return getExperiments()
},
},
actions: {
assignExperiments: assign({