mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
816 lines
24 KiB
Go
816 lines
24 KiB
Go
package site
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"html"
|
|
htmltemplate "html/template"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"text/template" // html/template escapes some nonces
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/justinas/nosurf"
|
|
"github.com/unrolled/secure"
|
|
"golang.org/x/sync/errgroup"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/appearance"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/entitlements"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
|
"github.com/coder/coder/v2/coderd/telemetry"
|
|
"github.com/coder/coder/v2/coderd/util/slice"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// We always embed the error page HTML because it it doesn't need to be built,
|
|
// and it's tiny and doesn't contribute much to the binary size.
|
|
var (
|
|
//go:embed static/error.html
|
|
errorHTML string
|
|
|
|
errorTemplate *htmltemplate.Template
|
|
|
|
//go:embed static/oauth2allow.html
|
|
oauthHTML string
|
|
|
|
oauthTemplate *htmltemplate.Template
|
|
)
|
|
|
|
func init() {
|
|
var err error
|
|
errorTemplate, err = htmltemplate.New("error").Parse(errorHTML)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
oauthTemplate, err = htmltemplate.New("error").Parse(oauthHTML)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
type Options struct {
|
|
CacheDir string
|
|
Database database.Store
|
|
Authorizer rbac.Authorizer
|
|
SiteFS fs.FS
|
|
OAuth2Configs *httpmw.OAuth2Configs
|
|
DocsURL string
|
|
BuildInfo codersdk.BuildInfoResponse
|
|
AppearanceFetcher *atomic.Pointer[appearance.Fetcher]
|
|
Entitlements *entitlements.Set
|
|
Telemetry telemetry.Reporter
|
|
Logger slog.Logger
|
|
HideAITasks bool
|
|
}
|
|
|
|
func New(opts *Options) (*Handler, error) {
|
|
if opts.AppearanceFetcher == nil {
|
|
daf := atomic.Pointer[appearance.Fetcher]{}
|
|
f := appearance.NewDefaultFetcher(opts.DocsURL)
|
|
daf.Store(&f)
|
|
opts.AppearanceFetcher = &daf
|
|
}
|
|
handler := &Handler{
|
|
opts: opts,
|
|
secureHeaders: secureHeaders(),
|
|
Entitlements: opts.Entitlements,
|
|
}
|
|
|
|
// 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 {
|
|
return nil, xerrors.Errorf("failed to parse html files: %w", err)
|
|
}
|
|
|
|
binHand, err := newBinHandler(opts)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("create bin handler: %w", err)
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
mux.Handle("/bin/", binHand)
|
|
mux.Handle("/", http.FileServer(
|
|
http.FS(
|
|
// OnlyFiles is a wrapper around the file system that prevents directory
|
|
// listings. Directory listings are not required for the site file system, so we
|
|
// exclude it as a security measure. In practice, this file system comes from our
|
|
// open source code base, but this is considered a best practice for serving
|
|
// static files.
|
|
OnlyFiles(opts.SiteFS))),
|
|
)
|
|
buildInfoResponse, err := json.Marshal(opts.BuildInfo)
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("failed to marshal build info: %w", err)
|
|
}
|
|
handler.buildInfoJSON = html.EscapeString(string(buildInfoResponse))
|
|
handler.handler = mux.ServeHTTP
|
|
|
|
handler.installScript, err = parseInstallScript(opts.SiteFS, opts.BuildInfo)
|
|
if err != nil {
|
|
opts.Logger.Warn(context.Background(), "could not parse install.sh, it will be unavailable", slog.Error(err))
|
|
}
|
|
|
|
return handler, nil
|
|
}
|
|
|
|
type Handler struct {
|
|
opts *Options
|
|
|
|
secureHeaders *secure.Secure
|
|
handler http.HandlerFunc
|
|
htmlTemplates *template.Template
|
|
buildInfoJSON string
|
|
installScript []byte
|
|
|
|
// RegionsFetcher will attempt to fetch the more detailed WorkspaceProxy data, but will fall back to the
|
|
// regions if the user does not have the correct permissions.
|
|
RegionsFetcher func(ctx context.Context) (any, error)
|
|
|
|
Entitlements *entitlements.Set
|
|
Experiments atomic.Pointer[codersdk.Experiments]
|
|
|
|
telemetryHTMLServedOnce sync.Once
|
|
}
|
|
|
|
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,
|
|
DocsURL: h.opts.DocsURL,
|
|
}
|
|
|
|
// 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 requesting assets, serve straight up with caching.
|
|
case reqFile == "assets" || strings.HasPrefix(reqFile, "assets/") || strings.HasPrefix(reqFile, "icon/"):
|
|
// It could make sense to cache 404s, but the problem is that during an
|
|
// upgrade a load balancer may route partially to the old server, and that
|
|
// would make new asset paths get cached as 404s and not load even once the
|
|
// new server was in place. To combat that, only cache if we have the file.
|
|
if h.exists(reqFile) && ShouldCacheFile(reqFile) {
|
|
rw.Header().Add("Cache-Control", "public, max-age=31536000, immutable")
|
|
}
|
|
// If the asset does not exist, this will return a 404.
|
|
h.handler.ServeHTTP(rw, r)
|
|
return
|
|
// If requesting the install.sh script, respond with the preprocessed version
|
|
// which contains the correct hostname and version information.
|
|
case reqFile == "install.sh":
|
|
if h.installScript == nil {
|
|
http.NotFound(rw, r)
|
|
return
|
|
}
|
|
rw.Header().Add("Content-Type", "text/plain; charset=utf-8")
|
|
http.ServeContent(rw, r, reqFile, time.Time{}, bytes.NewReader(h.installScript))
|
|
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.
|
|
func filePath(p string) string {
|
|
if !strings.HasPrefix(p, "/") {
|
|
p = "/" + p
|
|
}
|
|
return strings.TrimPrefix(path.Clean(p), "/")
|
|
}
|
|
|
|
func (h *Handler) exists(filePath string) bool {
|
|
f, err := h.opts.SiteFS.Open(filePath)
|
|
if err == nil {
|
|
_ = f.Close()
|
|
}
|
|
return err == nil
|
|
}
|
|
|
|
type htmlState struct {
|
|
CSRF csrfState
|
|
|
|
// Below are HTML escaped JSON strings of the respective structs.
|
|
ApplicationName string
|
|
LogoURL string
|
|
|
|
BuildInfo string
|
|
User string
|
|
Entitlements string
|
|
Appearance string
|
|
UserAppearance string
|
|
Experiments string
|
|
Regions string
|
|
DocsURL string
|
|
|
|
TasksTabVisible string
|
|
Permissions string
|
|
Organizations string
|
|
}
|
|
|
|
type csrfState struct {
|
|
Token string
|
|
}
|
|
|
|
func ShouldCacheFile(reqFile string) bool {
|
|
// Images, favicons and uniquely content hashed bundle assets should be
|
|
// cached. By default, we cache everything in the site/out directory except
|
|
// for deny-listed items enumerated here. The reason for this approach is that
|
|
// cache invalidation techniques should be used by default for all build
|
|
// processed assets. The scenarios where we don't use cache invalidation
|
|
// techniques are one-offs or things that should have invalidation in the
|
|
// future.
|
|
denyListedSuffixes := []string{
|
|
".html",
|
|
"worker.js",
|
|
}
|
|
|
|
for _, suffix := range denyListedSuffixes {
|
|
if strings.HasSuffix(reqFile, suffix) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// reportHTMLFirstServedAt sends a telemetry report when the first HTML is ever served.
|
|
// The purpose is to track the first time the first user opens the site.
|
|
func (h *Handler) reportHTMLFirstServedAt() {
|
|
// nolint:gocritic // Manipulating telemetry items is system-restricted.
|
|
// TODO(hugodutka): Add a telemetry context in RBAC.
|
|
ctx := dbauthz.AsSystemRestricted(context.Background())
|
|
itemKey := string(telemetry.TelemetryItemKeyHTMLFirstServedAt)
|
|
_, err := h.opts.Database.GetTelemetryItem(ctx, itemKey)
|
|
if err == nil {
|
|
// If the value is already set, then we reported it before.
|
|
// We don't need to report it again.
|
|
return
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
h.opts.Logger.Debug(ctx, "failed to get telemetry html first served at", slog.Error(err))
|
|
return
|
|
}
|
|
if err := h.opts.Database.InsertTelemetryItemIfNotExists(ctx, database.InsertTelemetryItemIfNotExistsParams{
|
|
Key: string(telemetry.TelemetryItemKeyHTMLFirstServedAt),
|
|
Value: time.Now().Format(time.RFC3339),
|
|
}); err != nil {
|
|
h.opts.Logger.Debug(ctx, "failed to set telemetry html first served at", slog.Error(err))
|
|
return
|
|
}
|
|
item, err := h.opts.Database.GetTelemetryItem(ctx, itemKey)
|
|
if err != nil {
|
|
h.opts.Logger.Debug(ctx, "failed to get telemetry html first served at", slog.Error(err))
|
|
return
|
|
}
|
|
h.opts.Telemetry.Report(&telemetry.Snapshot{
|
|
TelemetryItems: []telemetry.TelemetryItem{telemetry.ConvertTelemetryItem(item)},
|
|
})
|
|
}
|
|
|
|
func (h *Handler) serveHTML(resp http.ResponseWriter, request *http.Request, reqPath string, state htmlState) bool {
|
|
if data, err := h.renderHTMLWithState(request, reqPath, state); err == nil {
|
|
if reqPath == "" {
|
|
// Pass "index.html" to the ServeContent so the ServeContent sets the right content headers.
|
|
reqPath = "index.html"
|
|
}
|
|
// `Once` is used to reduce the volume of db calls and telemetry reports.
|
|
// It's fine to run the enclosed function multiple times, but it's unnecessary.
|
|
h.telemetryHTMLServedOnce.Do(func() {
|
|
go h.reportHTMLFirstServedAt()
|
|
})
|
|
http.ServeContent(resp, request, reqPath, time.Time{}, bytes.NewReader(data))
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func execTmpl(tmpl *template.Template, state htmlState) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
err := tmpl.Execute(&buf, state)
|
|
return buf.Bytes(), err
|
|
}
|
|
|
|
func userAppearanceSettingsFromRow(settings database.GetUserAppearanceSettingsRow) codersdk.UserAppearanceSettings {
|
|
return codersdk.UserAppearanceSettings{
|
|
ThemePreference: settings.ThemePreference,
|
|
ThemeMode: codersdk.ThemeMode(settings.ThemeMode),
|
|
ThemeLight: settings.ThemeLight,
|
|
ThemeDark: settings.ThemeDark,
|
|
TerminalFont: codersdk.TerminalFontName(settings.TerminalFont),
|
|
}
|
|
}
|
|
|
|
// 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 (h *Handler) renderHTMLWithState(r *http.Request, filePath string, state htmlState) ([]byte, error) {
|
|
af := *(h.opts.AppearanceFetcher.Load())
|
|
if filePath == "" {
|
|
filePath = "index.html"
|
|
}
|
|
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.
|
|
// We use a noop response writer because we don't want to write
|
|
// anything to the response and break the HTML, an error means we
|
|
// simply don't pre-populate the state.
|
|
noopRW := noopResponseWriter{}
|
|
apiKey, actor, ok := httpmw.ExtractAPIKey(noopRW, r, httpmw.ExtractAPIKeyConfig{
|
|
Optional: true,
|
|
DB: h.opts.Database,
|
|
OAuth2Configs: h.opts.OAuth2Configs,
|
|
// Special case for site, we can always disable refresh here because
|
|
// the frontend will perform API requests if this fails.
|
|
DisableSessionExpiryRefresh: true,
|
|
RedirectToLogin: false,
|
|
SessionTokenFunc: nil,
|
|
})
|
|
if !ok || apiKey == nil || actor == nil {
|
|
var cfg codersdk.AppearanceConfig
|
|
// nolint:gocritic // User is not expected to be signed in.
|
|
ctx := dbauthz.AsSystemRestricted(r.Context())
|
|
cfg, _ = af.Fetch(ctx)
|
|
state.ApplicationName = html.EscapeString(applicationNameOrDefault(cfg))
|
|
state.LogoURL = html.EscapeString(cfg.LogoURL)
|
|
return execTmpl(tmpl, state)
|
|
}
|
|
|
|
ctx := dbauthz.As(r.Context(), *actor)
|
|
|
|
var eg errgroup.Group
|
|
var user database.User
|
|
var userAppearance codersdk.UserAppearanceSettings
|
|
orgIDs := []uuid.UUID{}
|
|
var userOrgs []database.Organization
|
|
eg.Go(func() error {
|
|
var err error
|
|
user, err = h.opts.Database.GetUserByID(ctx, apiKey.UserID)
|
|
return err
|
|
})
|
|
eg.Go(func() error {
|
|
settings, err := h.opts.Database.GetUserAppearanceSettings(ctx, apiKey.UserID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
userAppearance = userAppearanceSettingsFromRow(settings)
|
|
return nil
|
|
})
|
|
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
|
|
})
|
|
eg.Go(func() error {
|
|
orgs, err := h.opts.Database.GetOrganizationsByUserID(ctx, database.GetOrganizationsByUserIDParams{
|
|
UserID: apiKey.UserID,
|
|
})
|
|
if err == nil {
|
|
userOrgs = orgs
|
|
}
|
|
// Don't fail the entire group if we can't fetch orgs.
|
|
return nil
|
|
})
|
|
err := eg.Wait()
|
|
if err == nil {
|
|
h.populateHTMLState(ctx, &state, af, actor, user, orgIDs, userOrgs, userAppearance)
|
|
}
|
|
|
|
return execTmpl(tmpl, state)
|
|
}
|
|
|
|
// populateHTMLState runs concurrent goroutines to populate all
|
|
// authenticated user metadata in the HTML state. This is extracted
|
|
// from renderHTMLWithState to reduce nesting complexity.
|
|
func (h *Handler) populateHTMLState(
|
|
ctx context.Context,
|
|
state *htmlState,
|
|
af appearance.Fetcher,
|
|
actor *rbac.Subject,
|
|
user database.User,
|
|
orgIDs []uuid.UUID,
|
|
userOrgs []database.Organization,
|
|
userAppearance codersdk.UserAppearanceSettings,
|
|
) {
|
|
var wg sync.WaitGroup
|
|
wg.Go(func() {
|
|
data, err := json.Marshal(db2sdk.User(user, orgIDs))
|
|
if err == nil {
|
|
state.User = html.EscapeString(string(data))
|
|
}
|
|
})
|
|
wg.Go(func() {
|
|
data, err := json.Marshal(userAppearance)
|
|
if err == nil {
|
|
state.UserAppearance = html.EscapeString(string(data))
|
|
}
|
|
})
|
|
if h.Entitlements != nil {
|
|
wg.Go(func() {
|
|
state.Entitlements = html.EscapeString(string(h.Entitlements.AsJSON()))
|
|
})
|
|
}
|
|
wg.Go(func() {
|
|
cfg, err := af.Fetch(ctx)
|
|
if err == nil {
|
|
appr, err := json.Marshal(cfg)
|
|
if err == nil {
|
|
state.Appearance = html.EscapeString(string(appr))
|
|
state.ApplicationName = html.EscapeString(applicationNameOrDefault(cfg))
|
|
state.LogoURL = html.EscapeString(cfg.LogoURL)
|
|
}
|
|
}
|
|
})
|
|
if h.RegionsFetcher != nil {
|
|
wg.Go(func() {
|
|
regions, err := h.RegionsFetcher(ctx)
|
|
if err == nil {
|
|
data, err := json.Marshal(regions)
|
|
if err == nil {
|
|
state.Regions = html.EscapeString(string(data))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
experiments := h.Experiments.Load()
|
|
if experiments != nil {
|
|
wg.Go(func() {
|
|
data, err := json.Marshal(experiments)
|
|
if err == nil {
|
|
state.Experiments = html.EscapeString(string(data))
|
|
}
|
|
})
|
|
}
|
|
wg.Go(func() {
|
|
data, err := json.Marshal(!h.opts.HideAITasks)
|
|
if err == nil {
|
|
state.TasksTabVisible = html.EscapeString(string(data))
|
|
}
|
|
})
|
|
wg.Go(func() {
|
|
sdkOrgs := slice.List(userOrgs, db2sdk.Organization)
|
|
data, err := json.Marshal(sdkOrgs)
|
|
if err == nil {
|
|
state.Organizations = html.EscapeString(string(data))
|
|
}
|
|
})
|
|
if h.opts.Authorizer != nil {
|
|
wg.Go(func() {
|
|
state.Permissions = h.renderPermissions(ctx, *actor)
|
|
})
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// permissionChecks is the single source of truth for site-wide
|
|
// permission checks, shared with the TypeScript frontend via
|
|
// permissions.json.
|
|
//
|
|
//go:embed permissions.json
|
|
var permissionChecksJSON []byte
|
|
|
|
var permissionChecks map[string]codersdk.AuthorizationCheck
|
|
|
|
func init() {
|
|
if err := json.Unmarshal(permissionChecksJSON, &permissionChecks); err != nil {
|
|
panic("failed to parse permissions.json: " + err.Error())
|
|
}
|
|
}
|
|
|
|
// renderPermissions checks all the site-wide permissions for the
|
|
// given actor and returns an HTML-escaped JSON string suitable for
|
|
// embedding in a meta tag.
|
|
func (h *Handler) renderPermissions(ctx context.Context, actor rbac.Subject) string {
|
|
response := make(codersdk.AuthorizationResponse)
|
|
for k, v := range permissionChecks {
|
|
// Resolve the "me" sentinel so permission checks
|
|
// run against the actual actor, matching the
|
|
// API-side handling in coderd/authorize.go.
|
|
ownerID := v.Object.OwnerID
|
|
if ownerID == codersdk.Me {
|
|
ownerID = actor.ID
|
|
}
|
|
obj := rbac.Object{
|
|
ID: v.Object.ResourceID,
|
|
Owner: ownerID,
|
|
OrgID: v.Object.OrganizationID,
|
|
AnyOrgOwner: v.Object.AnyOrgOwner,
|
|
Type: string(v.Object.ResourceType),
|
|
}
|
|
err := h.opts.Authorizer.Authorize(ctx, actor, policy.Action(v.Action), obj)
|
|
response[k] = err == nil
|
|
}
|
|
data, err := json.Marshal(response)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return html.EscapeString(string(data))
|
|
}
|
|
|
|
// noopResponseWriter is a response writer that does nothing.
|
|
type noopResponseWriter struct{}
|
|
|
|
func (noopResponseWriter) Header() http.Header { return http.Header{} }
|
|
func (noopResponseWriter) Write(p []byte) (int, error) { return len(p), nil }
|
|
func (noopResponseWriter) WriteHeader(int) {}
|
|
|
|
// 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() *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
|
|
// based on the app here.
|
|
permissions := strings.Join([]string{
|
|
// =() means it is disabled
|
|
"accelerometer=()",
|
|
"autoplay=()",
|
|
"battery=()",
|
|
"camera=()",
|
|
"document-domain=()",
|
|
"geolocation=()",
|
|
"gyroscope=()",
|
|
"magnetometer=()",
|
|
"microphone=(self)",
|
|
"midi=()",
|
|
"payment=()",
|
|
"usb=()",
|
|
"vr=()",
|
|
"screen-wake-lock=()",
|
|
"xr-spatial-tracking=()",
|
|
}, ", ")
|
|
|
|
return secure.New(secure.Options{
|
|
PermissionsPolicy: permissions,
|
|
|
|
// Prevent the browser from sending Referrer header with requests
|
|
ReferrerPolicy: "no-referrer",
|
|
})
|
|
}
|
|
|
|
// findAndParseHTMLFiles recursively walks the file system passed finding all *.html files.
|
|
// The template returned has all html files parsed.
|
|
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("")
|
|
|
|
rootPath := "."
|
|
err := fs.WalkDir(files, rootPath, func(filePath string, directory fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if directory.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
if filepath.Ext(directory.Name()) != ".html" {
|
|
return nil
|
|
}
|
|
|
|
file, err := files.Open(filePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tPath := strings.TrimPrefix(filePath, rootPath+string(filepath.Separator))
|
|
_, err = root.New(tPath).Parse(string(data))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return root, nil
|
|
}
|
|
|
|
type installScriptState struct {
|
|
Origin string
|
|
Version string
|
|
}
|
|
|
|
func parseInstallScript(files fs.FS, buildInfo codersdk.BuildInfoResponse) ([]byte, error) {
|
|
scriptFile, err := fs.ReadFile(files, "install.sh")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
script, err := template.New("install.sh").Parse(string(scriptFile))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
state := installScriptState{Origin: buildInfo.DashboardURL, Version: buildInfo.Version}
|
|
err = script.Execute(&buf, state)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// Action represents a link.
|
|
type Action struct {
|
|
// URL is set as the href property on the anchor. If empty, refreshes the
|
|
// page instead.
|
|
URL string
|
|
// Text is the displayed text of the button or link.
|
|
Text string
|
|
}
|
|
|
|
// ErrorPageData contains the variables that are found in
|
|
// site/static/error.html.
|
|
type ErrorPageData struct {
|
|
Status int
|
|
// HideStatus will remove the status code from the page.
|
|
HideStatus bool
|
|
Title string
|
|
Description string
|
|
Actions []Action
|
|
Warnings []string
|
|
AdditionalInfo string
|
|
|
|
RenderDescriptionMarkdown bool
|
|
}
|
|
|
|
// RenderStaticErrorPage renders the static error page. This is used by app
|
|
// requests to avoid dependence on the dashboard but maintain the ability to
|
|
// render a friendly error page on subdomains.
|
|
func RenderStaticErrorPage(rw http.ResponseWriter, r *http.Request, data ErrorPageData) {
|
|
type outerData struct {
|
|
Error ErrorPageData
|
|
|
|
ErrorDescriptionHTML htmltemplate.HTML
|
|
}
|
|
|
|
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
rw.WriteHeader(data.Status)
|
|
|
|
err := errorTemplate.Execute(rw, outerData{
|
|
Error: data,
|
|
ErrorDescriptionHTML: htmltemplate.HTML(data.Description), //nolint:gosec // gosec thinks this is user-input, but it is from Coder deployment configuration.
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to render error page: " + err.Error(),
|
|
Detail: fmt.Sprintf("Original error was: %d %s, %s", data.Status, data.Title, data.Description),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
func applicationNameOrDefault(cfg codersdk.AppearanceConfig) string {
|
|
if cfg.ApplicationName != "" {
|
|
return cfg.ApplicationName
|
|
}
|
|
return "Coder"
|
|
}
|
|
|
|
// OnlyFiles returns a new fs.FS that only contains files. If a directory is
|
|
// requested, os.ErrNotExist is returned. This prevents directory listings from
|
|
// being served.
|
|
func OnlyFiles(files fs.FS) fs.FS {
|
|
return justFilesSystem{FS: files}
|
|
}
|
|
|
|
type justFilesSystem struct {
|
|
FS fs.FS
|
|
}
|
|
|
|
func (jfs justFilesSystem) Open(name string) (fs.File, error) {
|
|
f, err := jfs.FS.Open(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
stat, err := f.Stat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Returning a 404 here does prevent the http.FileServer from serving
|
|
// index.* files automatically. Coder handles this above as all index pages
|
|
// are considered template files. So we never relied on this behavior.
|
|
if stat.IsDir() {
|
|
return nil, os.ErrNotExist
|
|
}
|
|
|
|
return f, nil
|
|
}
|
|
|
|
// RenderOAuthAllowData contains the variables that are found in
|
|
// site/static/oauth2allow.html.
|
|
type RenderOAuthAllowData struct {
|
|
AppIcon string
|
|
AppName string
|
|
CancelURI htmltemplate.URL
|
|
DashboardURL string
|
|
CSRFToken string
|
|
Username string
|
|
}
|
|
|
|
// RenderOAuthAllowPage renders the static page for a user to "Allow" an create
|
|
// a new oauth2 link with an external site. This is when Coder is acting as the
|
|
// identity provider.
|
|
//
|
|
// This has to be done statically because Golang has to handle the full request.
|
|
// It cannot defer to the FE typescript easily.
|
|
func RenderOAuthAllowPage(rw http.ResponseWriter, r *http.Request, data RenderOAuthAllowData) {
|
|
rw.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
|
|
// Prevent the consent page from being framed to mitigate
|
|
// clickjacking attacks (coder/security#121).
|
|
rw.Header().Set("Content-Security-Policy", "frame-ancestors 'none'")
|
|
rw.Header().Set("X-Frame-Options", "DENY")
|
|
|
|
err := oauthTemplate.Execute(rw, data)
|
|
if err != nil {
|
|
httpapi.Write(r.Context(), rw, http.StatusOK, codersdk.Response{
|
|
Message: "Failed to render oauth page: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
}
|