diff --git a/cmd/mattermost/commands/config.go b/cmd/mattermost/commands/config.go index 81ac765ecd39242b294430c80ca7e842e046f714..0b0e00f356dad2d72dd7510b6b088810dc6a5062 100644 --- a/cmd/mattermost/commands/config.go +++ b/cmd/mattermost/commands/config.go @@ -5,12 +5,14 @@ package commands import ( "encoding/json" - "errors" "os" + "github.com/pkg/errors" + "github.com/spf13/cobra" + "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/utils" - "github.com/spf13/cobra" + "github.com/mattermost/mattermost-server/web" ) var ConfigCmd = &cobra.Command{ @@ -25,9 +27,22 @@ var ValidateConfigCmd = &cobra.Command{ RunE: configValidateCmdF, } +var ConfigSubpathCmd = &cobra.Command{ + Use: "subpath", + Short: "Update client asset loading to use the configured subpath", + Long: "Update the hard-coded production client asset paths to take into account Mattermost running on a subpath.", + Example: ` config subpath + config subpath --path /mattermost + config subpath --path /`, + RunE: configSubpathCmdF, +} + func init() { + ConfigSubpathCmd.Flags().String("path", "", "Optional subpath; defaults to value in SiteURL") + ConfigCmd.AddCommand( ValidateConfigCmd, + ConfigSubpathCmd, ) RootCmd.AddCommand(ConfigCmd) } @@ -65,3 +80,22 @@ func configValidateCmdF(command *cobra.Command, args []string) error { CommandPrettyPrintln("The document is valid") return nil } + +func configSubpathCmdF(command *cobra.Command, args []string) error { + a, err := InitDBCommandContextCobra(command) + if err != nil { + return err + } + defer a.Shutdown() + + path, err := command.Flags().GetString("path") + if err != nil { + return errors.Wrap(err, "failed reading path") + } else if path == "" { + return web.UpdateAssetsSubpathFromConfig(a.Config()) + } else if err := web.UpdateAssetsSubpath(path); err != nil { + return errors.Wrap(err, "failed to update assets subpath") + } + + return nil +} diff --git a/web/helpers_test.go b/web/helpers_test.go new file mode 100644 index 0000000000000000000000000000000000000000..4e6a7ff6abce881b6d5cf0e4b2a6b17ca8dec715 --- /dev/null +++ b/web/helpers_test.go @@ -0,0 +1,15 @@ +package web_test + +const baseRootHtml = ` Mattermost

Cannot connect to Mattermost


We're having trouble connecting to Mattermost. If refreshing this page (Ctrl+R or Command+R) does not work, please verify that your computer is connected to the internet.


` + +const baseCss = `@font-face{font-family:FontAwesome;src:url(/static/files/674f50d287a8c48dc19ba404d20fe713.eot);src:url(/static/files/674f50d287a8c48dc19ba404d20fe713.eot?#iefix&v=4.7.0) format("embedded-opentype"),url(/static/files/af7ae505a9eed503f8b8e6982036873e.woff2) format("woff2"),url(/static/files/fee66e712a8a08eef5805a46892932ad.woff) format("woff"),url(/static/files/b06871f281fee6b241d60582ae9369b9.ttf) format("truetype"),url(/static/files/677433a0892aaed7b7d2628c313c9775.svg#fontawesomeregular) format("svg");font-weight:400;font-style:normal}` + +const subpathRootHtml = ` Mattermost

Cannot connect to Mattermost


We're having trouble connecting to Mattermost. If refreshing this page (Ctrl+R or Command+R) does not work, please verify that your computer is connected to the internet.


` + +const subpathCss = `@font-face{font-family:FontAwesome;src:url(/subpath/static/files/674f50d287a8c48dc19ba404d20fe713.eot);src:url(/subpath/static/files/674f50d287a8c48dc19ba404d20fe713.eot?#iefix&v=4.7.0) format("embedded-opentype"),url(/subpath/static/files/af7ae505a9eed503f8b8e6982036873e.woff2) format("woff2"),url(/subpath/static/files/fee66e712a8a08eef5805a46892932ad.woff) format("woff"),url(/subpath/static/files/b06871f281fee6b241d60582ae9369b9.ttf) format("truetype"),url(/subpath/static/files/677433a0892aaed7b7d2628c313c9775.svg#fontawesomeregular) format("svg");font-weight:400;font-style:normal}` + +const newSubpathRootHtml = ` Mattermost

Cannot connect to Mattermost


We're having trouble connecting to Mattermost. If refreshing this page (Ctrl+R or Command+R) does not work, please verify that your computer is connected to the internet.


` + +const newSubpathCss = `@font-face{font-family:FontAwesome;src:url(/nested/subpath/static/files/674f50d287a8c48dc19ba404d20fe713.eot);src:url(/nested/subpath/static/files/674f50d287a8c48dc19ba404d20fe713.eot?#iefix&v=4.7.0) format("embedded-opentype"),url(/nested/subpath/static/files/af7ae505a9eed503f8b8e6982036873e.woff2) format("woff2"),url(/nested/subpath/static/files/fee66e712a8a08eef5805a46892932ad.woff) format("woff"),url(/nested/subpath/static/files/b06871f281fee6b241d60582ae9369b9.ttf) format("truetype"),url(/nested/subpath/static/files/677433a0892aaed7b7d2628c313c9775.svg#fontawesomeregular) format("svg");font-weight:400;font-style:normal}` + +const resetRootHtml = ` Mattermost

Cannot connect to Mattermost


We're having trouble connecting to Mattermost. If refreshing this page (Ctrl+R or Command+R) does not work, please verify that your computer is connected to the internet.


` diff --git a/web/static.go b/web/static.go index 487526fdf1de6797b47f125c5501089fe9ca7cd6..1f76b2725fec6e513024c34a652a9515bad51ee8 100644 --- a/web/static.go +++ b/web/static.go @@ -18,6 +18,8 @@ import ( func (w *Web) InitStatic() { if *w.App.Config().ServiceSettings.WebserverMode != "disabled" { + UpdateAssetsSubpathFromConfig(w.App.Config()) + staticDir, _ := utils.FindDir(model.CLIENT_DIR) mlog.Debug(fmt.Sprintf("Using client directory at %v", staticDir)) diff --git a/web/subpath.go b/web/subpath.go new file mode 100644 index 0000000000000000000000000000000000000000..1bd7412c911f9b06519d7eb367507c24222cea90 --- /dev/null +++ b/web/subpath.go @@ -0,0 +1,130 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package web + +import ( + "crypto/sha256" + "encoding/base64" + "fmt" + "io/ioutil" + "net/url" + "os" + "path" + "path/filepath" + "regexp" + "strings" + + "github.com/pkg/errors" + + "github.com/mattermost/mattermost-server/mlog" + "github.com/mattermost/mattermost-server/model" + "github.com/mattermost/mattermost-server/utils" +) + +// UpdateAssetsSubpath rewrites assets in the /client directory to assume the application is hosted +// at the given subpath instead of at the root. No changes are written unless necessary. +func UpdateAssetsSubpath(subpath string) error { + if subpath == "" { + subpath = "/" + } + + staticDir, found := utils.FindDir(model.CLIENT_DIR) + if !found { + return errors.New("failed to find client dir") + } + + staticDir, err := filepath.EvalSymlinks(staticDir) + if err != nil { + return errors.Wrapf(err, "failed to resolve symlinks to %s", staticDir) + } + + rootHtmlPath := filepath.Join(staticDir, "root.html") + oldRootHtml, err := ioutil.ReadFile(rootHtmlPath) + if err != nil { + return errors.Wrap(err, "failed to open root.html") + } + + pathToReplace := "/static/" + newPath := path.Join(subpath, "static") + "/" + + // Determine if a previous subpath had already been rewritten into the assets. + reWebpackPublicPathScript := regexp.MustCompile("window.publicPath='([^']+)'") + alreadyRewritten := false + if matches := reWebpackPublicPathScript.FindStringSubmatch(string(oldRootHtml)); matches != nil { + pathToReplace = matches[1] + alreadyRewritten = true + } + + if pathToReplace == newPath { + mlog.Debug("No rewrite required for static assets", mlog.String("path", pathToReplace)) + return nil + } + + mlog.Debug("Rewriting static assets", mlog.String("from_path", pathToReplace), mlog.String("to_path", newPath)) + + newRootHtml := string(oldRootHtml) + + // Compute the sha256 hash for the inline script and reference same in the CSP meta tag. + // This allows the inline script defining `window.publicPath` to bypass CSP protections. + script := fmt.Sprintf("window.publicPath='%s'", newPath) + scriptHash := sha256.Sum256([]byte(script)) + + reCSP := regexp.MustCompile(``) + newRootHtml = reCSP.ReplaceAllLiteralString(newRootHtml, fmt.Sprintf( + ``, + base64.StdEncoding.EncodeToString(scriptHash[:]), + )) + + // Rewrite the root.html references to `/static/*` to include the given subpath. This + // potentially includes a previously injected inline script. + newRootHtml = strings.Replace(newRootHtml, pathToReplace, newPath, -1) + + // Inject the script, if needed, to define `window.publicPath`. + if !alreadyRewritten { + newRootHtml = strings.Replace(newRootHtml, "", fmt.Sprintf("", script), 1) + } + + // Write out the updated root.html. + if err = ioutil.WriteFile(rootHtmlPath, []byte(newRootHtml), 0); err != nil { + return errors.Wrapf(err, "failed to update root.html with subpath %s", subpath) + } + + // Rewrite the *.css references to `/static/*` (or a previously rewritten subpath). + err = filepath.Walk(staticDir, func(walkPath string, info os.FileInfo, err error) error { + if filepath.Ext(walkPath) == ".css" { + if oldCss, err := ioutil.ReadFile(walkPath); err != nil { + return errors.Wrapf(err, "failed to open %s", walkPath) + } else { + newCss := strings.Replace(string(oldCss), pathToReplace, newPath, -1) + if err = ioutil.WriteFile(walkPath, []byte(newCss), 0); err != nil { + return errors.Wrapf(err, "failed to update %s with subpath %s", walkPath, subpath) + } + } + } + + return nil + }) + if err != nil { + return errors.Wrapf(err, "error walking %s", staticDir) + } + + return nil +} + +// UpdateAssetsSubpathFromConfig uses UpdateAssetsSubpath and any path defined in the SiteURL. +func UpdateAssetsSubpathFromConfig(config *model.Config) error { + // Don't rewrite in development environments, since webpack in developer mode constantly + // updates the assets and must be configured separately. + if model.BuildNumber == "dev" { + mlog.Debug("Skipping update to assets subpath since dev build") + return nil + } + + u, err := url.Parse(*config.ServiceSettings.SiteURL) + if err != nil { + return errors.Wrap(err, "failed to parse SiteURL from config") + } + + return UpdateAssetsSubpath(u.Path) +} diff --git a/web/subpath_test.go b/web/subpath_test.go new file mode 100644 index 0000000000000000000000000000000000000000..92b1a5d3c1b4d7f5a025d22cd9c0b6c88cd55bf0 --- /dev/null +++ b/web/subpath_test.go @@ -0,0 +1,103 @@ +package web_test + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/model" + "github.com/mattermost/mattermost-server/web" +) + +func TestUpdateAssetsSubpath(t *testing.T) { + t.Run("no client dir", func(t *testing.T) { + tempDir, err := ioutil.TempDir("", "test_update_assets_subpath") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + os.Chdir(tempDir) + + err = web.UpdateAssetsSubpath("/") + require.Error(t, err) + }) + + t.Run("valid", func(t *testing.T) { + tempDir, err := ioutil.TempDir("", "test_update_assets_subpath") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + os.Chdir(tempDir) + + err = os.Mkdir(model.CLIENT_DIR, 0700) + require.NoError(t, err) + + testCases := []struct { + Description string + RootHTML string + MainCSS string + Subpath string + ExpectedRootHTML string + ExpectedMainCSS string + }{ + { + "no changes required, empty subpath provided", + baseRootHtml, + baseCss, + "", + baseRootHtml, + baseCss, + }, + { + "no changes required", + baseRootHtml, + baseCss, + "/", + baseRootHtml, + baseCss, + }, + { + "subpath", + baseRootHtml, + baseCss, + "/subpath", + subpathRootHtml, + subpathCss, + }, + { + "new subpath from old", + subpathRootHtml, + subpathCss, + "/nested/subpath", + newSubpathRootHtml, + newSubpathCss, + }, + { + "resetting to /", + subpathRootHtml, + subpathCss, + "/", + resetRootHtml, + baseCss, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.Description, func(t *testing.T) { + ioutil.WriteFile(filepath.Join(tempDir, model.CLIENT_DIR, "root.html"), []byte(testCase.RootHTML), 0700) + ioutil.WriteFile(filepath.Join(tempDir, model.CLIENT_DIR, "main.css"), []byte(testCase.MainCSS), 0700) + err := web.UpdateAssetsSubpath(testCase.Subpath) + require.NoError(t, err) + + contents, err := ioutil.ReadFile(filepath.Join(tempDir, model.CLIENT_DIR, "root.html")) + require.NoError(t, err) + require.Equal(t, testCase.ExpectedRootHTML, string(contents)) + + contents, err = ioutil.ReadFile(filepath.Join(tempDir, model.CLIENT_DIR, "main.css")) + require.NoError(t, err) + require.Equal(t, testCase.ExpectedMainCSS, string(contents)) + + }) + } + }) +}