diff --git a/app/router/secure.go b/app/router/secure.go new file mode 100644 index 000000000..c4d08d94a --- /dev/null +++ b/app/router/secure.go @@ -0,0 +1,45 @@ +// Copyright 2023 Harness, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package router + +import ( + "github.com/harness/gitness/types" + + "github.com/unrolled/secure" +) + +// NewSecure returns a new secure.Secure middleware to enforce security best practices +// for the user interface (not meant APIs). +func NewSecure(config *types.Config) *secure.Secure { + return secure.New( + secure.Options{ + AllowedHosts: config.Secure.AllowedHosts, + HostsProxyHeaders: config.Secure.HostsProxyHeaders, + SSLRedirect: config.Secure.SSLRedirect, + SSLTemporaryRedirect: config.Secure.SSLTemporaryRedirect, + SSLHost: config.Secure.SSLHost, + SSLProxyHeaders: config.Secure.SSLProxyHeaders, + STSSeconds: config.Secure.STSSeconds, + STSIncludeSubdomains: config.Secure.STSIncludeSubdomains, + STSPreload: config.Secure.STSPreload, + ForceSTSHeader: config.Secure.ForceSTSHeader, + FrameDeny: config.Secure.FrameDeny, + ContentTypeNosniff: config.Secure.ContentTypeNosniff, + BrowserXssFilter: config.Secure.BrowserXSSFilter, + ContentSecurityPolicy: config.Secure.ContentSecurityPolicy, + ReferrerPolicy: config.Secure.ReferrerPolicy, + }, + ) +} diff --git a/app/router/web.go b/app/router/web.go index 1683213be..3f844d813 100644 --- a/app/router/web.go +++ b/app/router/web.go @@ -21,7 +21,6 @@ import ( "github.com/harness/gitness/app/api/openapi" "github.com/harness/gitness/app/api/render" "github.com/harness/gitness/app/auth/authn" - "github.com/harness/gitness/types" "github.com/harness/gitness/web" "github.com/go-chi/chi/v5" @@ -33,34 +32,14 @@ import ( // NewWebHandler returns a new WebHandler. func NewWebHandler( - config *types.Config, authenticator authn.Authenticator, openapi openapi.Service, + sec *secure.Secure, + publicResourceCreationEnabled bool, + uiSourceOverride string, ) http.Handler { // Use go-chi router for inner routing r := chi.NewRouter() - // create middleware to enforce security best practices for - // the user interface. note that theis middleware is only used - // when serving the user interface (not found handler, below). - sec := secure.New( - secure.Options{ - AllowedHosts: config.Secure.AllowedHosts, - HostsProxyHeaders: config.Secure.HostsProxyHeaders, - SSLRedirect: config.Secure.SSLRedirect, - SSLTemporaryRedirect: config.Secure.SSLTemporaryRedirect, - SSLHost: config.Secure.SSLHost, - SSLProxyHeaders: config.Secure.SSLProxyHeaders, - STSSeconds: config.Secure.STSSeconds, - STSIncludeSubdomains: config.Secure.STSIncludeSubdomains, - STSPreload: config.Secure.STSPreload, - ForceSTSHeader: config.Secure.ForceSTSHeader, - FrameDeny: config.Secure.FrameDeny, - ContentTypeNosniff: config.Secure.ContentTypeNosniff, - BrowserXssFilter: config.Secure.BrowserXSSFilter, - ContentSecurityPolicy: config.Secure.ContentSecurityPolicy, - ReferrerPolicy: config.Secure.ReferrerPolicy, - }, - ) // openapi endpoints // TODO: this should not be generated and marshaled on the fly every time? @@ -102,9 +81,9 @@ func NewWebHandler( // which in turn serves the user interface. r.With( sec.Handler, - middlewareweb.PublicAccess(config.PublicResourceCreationEnabled, authenticator), + middlewareweb.PublicAccess(publicResourceCreationEnabled, authenticator), ).NotFound( - web.Handler(), + web.Handler(uiSourceOverride), ) return r diff --git a/app/router/wire.go b/app/router/wire.go index 681f12898..c602dc0e2 100644 --- a/app/router/wire.go +++ b/app/router/wire.go @@ -136,7 +136,12 @@ func ProvideRouter( infraProviderCtrl, migrateCtrl, gitspaceCtrl, aiagentCtrl, capabilitiesCtrl, usageSender) routers[2] = NewAPIRouter(apiHandler) - webHandler := NewWebHandler(config, authenticator, openapi) + sec := NewSecure(config) + webHandler := NewWebHandler( + authenticator, openapi, sec, + config.PublicResourceCreationEnabled, + config.Development.UISourceOverride, + ) routers[3] = NewWebRouter(webHandler) return NewRouter(routers) diff --git a/types/config.go b/types/config.go index 72934ad72..637b5c333 100644 --- a/types/config.go +++ b/types/config.go @@ -507,4 +507,8 @@ type Config struct { ChunkSize string `envconfig:"GITNESS_USAGE_METRICS_CHUNK_SIZE" default:"10MiB"` MaxWorkers int `envconfig:"GITNESS_USAGE_METRICS_MAX_WORKERS" default:"50"` } + + Development struct { + UISourceOverride string `envconfig:"GITNESS_DEVELOPMENT_UI_SOURCE_OVERRIDE"` + } } diff --git a/web/dist.go b/web/dist.go index 275bc7546..ed20ba97c 100644 --- a/web/dist.go +++ b/web/dist.go @@ -19,6 +19,7 @@ import ( "bytes" "context" "embed" + "errors" "fmt" "io" "io/fs" @@ -26,45 +27,64 @@ import ( "os" "path" "time" + + "github.com/rs/zerolog/log" ) //go:embed dist/* -var UI embed.FS -var remoteEntryContent []byte -var fileMap map[string]bool +var EmbeddedUIFS embed.FS -const distPath = "dist" -const remoteEntryJS = "remoteEntry.js" -const remoteEntryJSFullPath = "/" + remoteEntryJS +const ( + distPath = "dist" + remoteEntryJS = "remoteEntry.js" + remoteEntryJSFullPath = "/" + remoteEntryJS +) // Handler returns an http.HandlerFunc that servers the // static content from the embedded file system. // //nolint:gocognit // refactor if required. -func Handler() http.HandlerFunc { - // Load the files subdirectory - fs, err := fs.Sub(UI, distPath) +func Handler(uiSourceOverride string) http.HandlerFunc { + fs, err := fs.Sub(EmbeddedUIFS, distPath) if err != nil { - panic(err) + panic(fmt.Errorf("failed to load embedded files: %w", err)) } - // Create an http.FileServer to serve the - // contents of the files subdiretory. + // override UI source if provided (for local development) + if uiSourceOverride != "" { + log.Info().Msgf("Starting with alternate UI located at %q", uiSourceOverride) + fs = os.DirFS(uiSourceOverride) + } + + remoteEntryContent, remoteEntryExists, err := readRemoteEntryJSContent(fs) + if err != nil { + panic(fmt.Errorf("failed to read remote entry JS content: %w", err)) + } + + fileMap, err := createFileMapForDistFolder(fs) + if err != nil { + panic(fmt.Errorf("failed to create file map for dist folder: %w", err)) + } + + publicIndexFileExists := fileMap["index_public.html"] + + // Create an http.FileServer to serve the contents of the files subdiretory. handler := http.FileServer(http.FS(fs)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Get the file base path basePath := path.Base(r.URL.Path) - // If the file exists in dist/ then serve it from "/". - // Otherwise, rewrite the request to "/" so /index.html is served - if fileNotFoundInDist(basePath) { + // fallback to root in case the file doesn't exist so /index.html is served + if !fileMap[basePath] { r.URL.Path = "/" } else { r.URL.Path = "/" + basePath } - if RenderPublicAccessFrom(r.Context()) && + // handle public access + if publicIndexFileExists && + RenderPublicAccessFrom(r.Context()) && (r.URL.Path == "/" || r.URL.Path == "/index.html") { r.URL.Path = "/index_public.html" } @@ -80,7 +100,7 @@ func Handler() http.HandlerFunc { } // Serve /remoteEntry.js from memory - if r.URL.Path == remoteEntryJSFullPath { + if remoteEntryExists && r.URL.Path == remoteEntryJSFullPath { http.ServeContent(w, r, r.URL.Path, time.Now(), bytes.NewReader(remoteEntryContent)) } else { handler.ServeHTTP(w, r) @@ -88,36 +108,19 @@ func Handler() http.HandlerFunc { }) } -func init() { - err := readRemoteEntryJSContent() - if err != nil { - panic(err) +func readRemoteEntryJSContent(fileSystem fs.FS) ([]byte, bool, error) { + file, err := fileSystem.Open(remoteEntryJS) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil } - - err = createFileMapForDistFolder() if err != nil { - panic(err) + return nil, false, fmt.Errorf("failed to open remoteEntry.js: %w", err) } -} - -func readRemoteEntryJSContent() error { - fs, err := fs.Sub(UI, distPath) - - if err != nil { - return fmt.Errorf("failed to open /dist: %w", err) - } - - file, err := fs.Open(remoteEntryJS) - - if err != nil { - return fmt.Errorf("failed to open remoteEntry.js: %w", err) - } - defer file.Close() - buf, err := io.ReadAll(file) + buf, err := io.ReadAll(file) if err != nil { - return fmt.Errorf("failed to read remoteEntry.js: %w", err) + return nil, false, fmt.Errorf("failed to read remoteEntry.js: %w", err) } enableCDN := os.Getenv("ENABLE_CDN") @@ -126,31 +129,28 @@ func readRemoteEntryJSContent() error { enableCDN = "false" } - remoteEntryContent = bytes.Replace(buf, []byte("__ENABLE_CDN__"), []byte(enableCDN), 1) - - return nil + return bytes.Replace(buf, []byte("__ENABLE_CDN__"), []byte(enableCDN), 1), true, nil } -func createFileMapForDistFolder() error { - fileMap = make(map[string]bool) +func createFileMapForDistFolder(fileSystem fs.FS) (map[string]bool, error) { + fileMap := make(map[string]bool) - err := fs.WalkDir(UI, distPath, func(path string, _ fs.DirEntry, err error) error { + err := fs.WalkDir(fileSystem, ".", func(path string, _ fs.DirEntry, err error) error { if err != nil { - return fmt.Errorf("failed to build file map for path %q: %w", path, err) + return fmt.Errorf("failed to read file info for %q: %w", path, err) } - if path != distPath { // exclude "dist" from file map + if path != "." { fileMap[path] = true } return nil }) + if err != nil { + return nil, fmt.Errorf("failed to build file map: %w", err) + } - return err -} - -func fileNotFoundInDist(path string) bool { - return !fileMap[distPath+"/"+path] + return fileMap, nil } // renderPublicAccessKey is the context key for storing and retrieving whether public access should be rendered.