mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
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:
+2
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user