diff --git a/coderd/audit.go b/coderd/audit.go index e0f000c495a3a..5a2c41b3e5ad2 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -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)) } } diff --git a/coderd/coderd.go b/coderd/coderd.go index fe59fd8726467..af03f69f8a89f 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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 diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 465462781557e..7b3b8310eaf3c 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -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, + } +} diff --git a/coderd/members.go b/coderd/members.go index 293f007dd400d..67ab19cf45687 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -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 } diff --git a/coderd/roles.go b/coderd/roles.go index a067173300e43..177a5301eae00 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -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 { diff --git a/coderd/users.go b/coderd/users.go index 7cb05dacacd60..04dbab057ffe2 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -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 } diff --git a/enterprise/coderd/appearance.go b/enterprise/coderd/appearance.go index 6f8ef8500e21f..c4dc8b34e1941 100644 --- a/enterprise/coderd/appearance.go +++ b/enterprise/coderd/appearance.go @@ -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 { diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 5da6f392fc431..0021fd4df63c4 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -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 } diff --git a/site/index.html b/site/index.html index ac18266dcda35..61cb49be7b129 100644 --- a/site/index.html +++ b/site/index.html @@ -12,11 +12,14 @@ - + - + + + + { + // 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), }, }, diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index 132b01d496e84..8c5ce5dd8edfc 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -99,7 +99,22 @@ export const isAuthenticated = (data?: AuthData): data is AuthenticatedData => data !== undefined && "user" in data const loadInitialAuthData = async (): Promise => { - 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({ diff --git a/site/src/xServices/entitlements/entitlementsXService.ts b/site/src/xServices/entitlements/entitlementsXService.ts index 11fb7f2a3008b..b1257cb4d18e4 100644 --- a/site/src/xServices/entitlements/entitlementsXService.ts +++ b/site/src/xServices/entitlements/entitlementsXService.ts @@ -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() + }, }, }, ) diff --git a/site/src/xServices/experiments/experimentsMachine.ts b/site/src/xServices/experiments/experimentsMachine.ts index 399eaf6c2650b..0c8eef6cf4a55 100644 --- a/site/src/xServices/experiments/experimentsMachine.ts +++ b/site/src/xServices/experiments/experimentsMachine.ts @@ -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({