diff --git a/api4/file.go b/api4/file.go index 0b0973b309..bd8c46405f 100644 --- a/api4/file.go +++ b/api4/file.go @@ -312,13 +312,13 @@ func getPublicFile(c *Context, w http.ResponseWriter, r *http.Request) { if len(hash) == 0 { c.Err = model.NewAppError("getPublicFile", "api.file.get_file.public_invalid.app_error", nil, "", http.StatusBadRequest) - utils.RenderWebAppError(w, r, c.Err, c.App.AsymmetricSigningKey()) + utils.RenderWebAppError(c.App.Config(), w, r, c.Err, c.App.AsymmetricSigningKey()) return } if hash != app.GeneratePublicLinkHash(info.Id, *c.App.Config().FileSettings.PublicLinkSalt) { c.Err = model.NewAppError("getPublicFile", "api.file.get_file.public_invalid.app_error", nil, "", http.StatusBadRequest) - utils.RenderWebAppError(w, r, c.Err, c.App.AsymmetricSigningKey()) + utils.RenderWebAppError(c.App.Config(), w, r, c.Err, c.App.AsymmetricSigningKey()) return } diff --git a/api4/oauth.go b/api4/oauth.go index af3d83e179..b858267ee5 100644 --- a/api4/oauth.go +++ b/api4/oauth.go @@ -314,7 +314,7 @@ func deauthorizeOAuthApp(c *Context, w http.ResponseWriter, r *http.Request) { func authorizeOAuthPage(c *Context, w http.ResponseWriter, r *http.Request) { if !c.App.Config().ServiceSettings.EnableOAuthServiceProvider { err := model.NewAppError("authorizeOAuth", "api.oauth.authorize_oauth.disabled.app_error", nil, "", http.StatusNotImplemented) - utils.RenderWebAppError(w, r, err, c.App.AsymmetricSigningKey()) + utils.RenderWebAppError(c.App.Config(), w, r, err, c.App.AsymmetricSigningKey()) return } @@ -327,13 +327,13 @@ func authorizeOAuthPage(c *Context, w http.ResponseWriter, r *http.Request) { } if err := authRequest.IsValid(); err != nil { - utils.RenderWebAppError(w, r, err, c.App.AsymmetricSigningKey()) + utils.RenderWebAppError(c.App.Config(), w, r, err, c.App.AsymmetricSigningKey()) return } oauthApp, err := c.App.GetOAuthApp(authRequest.ClientId) if err != nil { - utils.RenderWebAppError(w, r, err, c.App.AsymmetricSigningKey()) + utils.RenderWebAppError(c.App.Config(), w, r, err, c.App.AsymmetricSigningKey()) return } @@ -345,7 +345,7 @@ func authorizeOAuthPage(c *Context, w http.ResponseWriter, r *http.Request) { if !oauthApp.IsValidRedirectURL(authRequest.RedirectUri) { err := model.NewAppError("authorizeOAuthPage", "api.oauth.allow_oauth.redirect_callback.app_error", nil, "", http.StatusBadRequest) - utils.RenderWebAppError(w, r, err, c.App.AsymmetricSigningKey()) + utils.RenderWebAppError(c.App.Config(), w, r, err, c.App.AsymmetricSigningKey()) return } @@ -362,7 +362,7 @@ func authorizeOAuthPage(c *Context, w http.ResponseWriter, r *http.Request) { redirectUrl, err := c.App.AllowOAuthAppAccessToUser(c.Session.UserId, authRequest) if err != nil { - utils.RenderWebAppError(w, r, err, c.App.AsymmetricSigningKey()) + utils.RenderWebAppError(c.App.Config(), w, r, err, c.App.AsymmetricSigningKey()) return } @@ -443,7 +443,7 @@ func completeOAuth(c *Context, w http.ResponseWriter, r *http.Request) { code := r.URL.Query().Get("code") if len(code) == 0 { - utils.RenderWebError(w, r, http.StatusTemporaryRedirect, url.Values{ + utils.RenderWebError(c.App.Config(), w, r, http.StatusTemporaryRedirect, url.Values{ "type": []string{"oauth_missing_code"}, "service": []string{strings.Title(service)}, }, c.App.AsymmetricSigningKey()) @@ -467,7 +467,7 @@ func completeOAuth(c *Context, w http.ResponseWriter, r *http.Request) { if action == model.OAUTH_ACTION_MOBILE { w.Write([]byte(err.ToJson())) } else { - utils.RenderWebAppError(w, r, err, c.App.AsymmetricSigningKey()) + utils.RenderWebAppError(c.App.Config(), w, r, err, c.App.AsymmetricSigningKey()) } return } @@ -479,7 +479,7 @@ func completeOAuth(c *Context, w http.ResponseWriter, r *http.Request) { if action == model.OAUTH_ACTION_MOBILE { w.Write([]byte(err.ToJson())) } else { - utils.RenderWebAppError(w, r, err, c.App.AsymmetricSigningKey()) + utils.RenderWebAppError(c.App.Config(), w, r, err, c.App.AsymmetricSigningKey()) } return } @@ -564,7 +564,7 @@ func signupWithOAuth(c *Context, w http.ResponseWriter, r *http.Request) { } if !*c.App.Config().TeamSettings.EnableUserCreation { - utils.RenderWebError(w, r, http.StatusBadRequest, url.Values{ + utils.RenderWebError(c.App.Config(), w, r, http.StatusBadRequest, url.Values{ "message": []string{utils.T("api.oauth.singup_with_oauth.disabled.app_error")}, }, c.App.AsymmetricSigningKey()) return diff --git a/app/app.go b/app/app.go index 3f70974cff..2041a24fef 100644 --- a/app/app.go +++ b/app/app.go @@ -9,6 +9,7 @@ import ( "html/template" "net" "net/http" + "path" "reflect" "strings" "sync" @@ -108,10 +109,12 @@ func New(options ...Option) (outApp *App, outErr error) { panic("Only one App should exist at a time. Did you forget to call Shutdown()?") } + rootRouter := mux.NewRouter() + app := &App{ goroutineExitSignal: make(chan struct{}, 1), Srv: &Server{ - Router: mux.NewRouter(), + RootRouter: rootRouter, }, sessionCache: utils.NewLru(model.SESSION_CACHE_SIZE), configFile: "config.json", @@ -206,10 +209,21 @@ func New(options ...Option) (outApp *App, outErr error) { app.initJobs() - app.initBuiltInPlugins() + subpath, err := utils.GetSubpathFromConfig(app.Config()) + if err != nil { + return nil, errors.Wrap(err, "failed to parse SiteURL subpath") + } + app.Srv.Router = app.Srv.RootRouter.PathPrefix(subpath).Subrouter() app.Srv.Router.HandleFunc("/plugins/{plugin_id:[A-Za-z0-9\\_\\-\\.]+}", app.ServePluginRequest) app.Srv.Router.HandleFunc("/plugins/{plugin_id:[A-Za-z0-9\\_\\-\\.]+}/{anything:.*}", app.ServePluginRequest) + // If configured with a subpath, redirect 404s at the root back into the subpath. + if subpath != "/" { + app.Srv.RootRouter.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.URL.Path = path.Join(subpath, r.URL.Path) + http.Redirect(w, r, r.URL.String(), http.StatusFound) + }) + } app.Srv.Router.NotFoundHandler = http.HandlerFunc(app.Handle404) app.Srv.WebSocketRouter = &WebSocketRouter{ @@ -217,6 +231,8 @@ func New(options ...Option) (outApp *App, outErr error) { handlers: make(map[string]webSocketHandler), } + app.initBuiltInPlugins() + return app, nil } @@ -510,7 +526,7 @@ func (a *App) Handle404(w http.ResponseWriter, r *http.Request) { mlog.Debug(fmt.Sprintf("%v: code=404 ip=%v", r.URL.Path, utils.GetIpAddress(r))) - utils.RenderWebAppError(w, r, err, a.AsymmetricSigningKey()) + utils.RenderWebAppError(a.Config(), w, r, err, a.AsymmetricSigningKey()) } // This function migrates the default built in roles from code/config to the database. diff --git a/app/server.go b/app/server.go index 7d229201de..d71a884d2e 100644 --- a/app/server.go +++ b/app/server.go @@ -29,10 +29,17 @@ import ( type Server struct { Store store.Store WebSocketRouter *WebSocketRouter - Router *mux.Router - Server *http.Server - ListenAddr *net.TCPAddr - RateLimiter *RateLimiter + + // RootRouter is the starting point for all HTTP requests to the server. + RootRouter *mux.Router + + // Router is the starting point for all web, api4 and ws requests to the server. It differs + // from RootRouter only if the SiteURL contains a /subpath. + Router *mux.Router + + Server *http.Server + ListenAddr *net.TCPAddr + RateLimiter *RateLimiter didFinishListen chan struct{} } @@ -99,7 +106,7 @@ func redirectHTTPToHTTPS(w http.ResponseWriter, r *http.Request) { func (a *App) StartServer() error { mlog.Info("Starting Server...") - var handler http.Handler = &CorsWrapper{a.Config, a.Srv.Router} + var handler http.Handler = &CorsWrapper{a.Config, a.Srv.RootRouter} if *a.Config().RateLimitSettings.Enable { mlog.Info("RateLimiter is enabled") diff --git a/cmd/mattermost/commands/config.go b/cmd/mattermost/commands/config.go index 0b0e00f356..d9881b050a 100644 --- a/cmd/mattermost/commands/config.go +++ b/cmd/mattermost/commands/config.go @@ -12,7 +12,6 @@ import ( "github.com/mattermost/mattermost-server/model" "github.com/mattermost/mattermost-server/utils" - "github.com/mattermost/mattermost-server/web" ) var ConfigCmd = &cobra.Command{ @@ -92,8 +91,8 @@ func configSubpathCmdF(command *cobra.Command, args []string) error { 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 utils.UpdateAssetsSubpathFromConfig(a.Config()) + } else if err := utils.UpdateAssetsSubpath(path); err != nil { return errors.Wrap(err, "failed to update assets subpath") } diff --git a/utils/api.go b/utils/api.go index b5e490eb79..d14f316b69 100644 --- a/utils/api.go +++ b/utils/api.go @@ -11,6 +11,7 @@ import ( "html/template" "net/http" "net/url" + "path" "strings" "github.com/mattermost/mattermost-server/model" @@ -35,24 +36,26 @@ func OriginChecker(allowedOrigins string) func(*http.Request) bool { } } -func RenderWebAppError(w http.ResponseWriter, r *http.Request, err *model.AppError, s crypto.Signer) { - RenderWebError(w, r, err.StatusCode, url.Values{ +func RenderWebAppError(config *model.Config, w http.ResponseWriter, r *http.Request, err *model.AppError, s crypto.Signer) { + RenderWebError(config, w, r, err.StatusCode, url.Values{ "message": []string{err.Message}, }, s) } -func RenderWebError(w http.ResponseWriter, r *http.Request, status int, params url.Values, s crypto.Signer) { +func RenderWebError(config *model.Config, w http.ResponseWriter, r *http.Request, status int, params url.Values, s crypto.Signer) { queryString := params.Encode() + subpath, _ := GetSubpathFromConfig(config) + h := crypto.SHA256 sum := h.New() - sum.Write([]byte("/error?" + queryString)) + sum.Write([]byte(path.Join(subpath, "error") + "?" + queryString)) signature, err := s.Sign(rand.Reader, sum.Sum(nil), h) if err != nil { http.Error(w, "", http.StatusInternalServerError) return } - destination := "/error?" + queryString + "&s=" + base64.URLEncoding.EncodeToString(signature) + destination := path.Join(subpath, "error") + "?" + queryString + "&s=" + base64.URLEncoding.EncodeToString(signature) if status >= 300 && status < 400 { http.Redirect(w, r, destination, status) diff --git a/utils/api_test.go b/utils/api_test.go index 5e41c7bfef..d84207eaad 100644 --- a/utils/api_test.go +++ b/utils/api_test.go @@ -18,6 +18,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/model" ) func TestRenderWebError(t *testing.T) { @@ -25,7 +27,7 @@ func TestRenderWebError(t *testing.T) { w := httptest.NewRecorder() key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) - RenderWebError(w, r, http.StatusTemporaryRedirect, url.Values{ + RenderWebError(&model.Config{}, w, r, http.StatusTemporaryRedirect, url.Values{ "foo": []string{"bar"}, }, key) diff --git a/web/subpath.go b/utils/subpath.go similarity index 89% rename from web/subpath.go rename to utils/subpath.go index 1bd7412c91..cddc90fa48 100644 --- a/web/subpath.go +++ b/utils/subpath.go @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -package web +package utils import ( "crypto/sha256" @@ -19,7 +19,6 @@ import ( "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 @@ -29,7 +28,7 @@ func UpdateAssetsSubpath(subpath string) error { subpath = "/" } - staticDir, found := utils.FindDir(model.CLIENT_DIR) + staticDir, found := FindDir(model.CLIENT_DIR) if !found { return errors.New("failed to find client dir") } @@ -121,10 +120,29 @@ func UpdateAssetsSubpathFromConfig(config *model.Config) error { return nil } - u, err := url.Parse(*config.ServiceSettings.SiteURL) + subpath, err := GetSubpathFromConfig(config) if err != nil { - return errors.Wrap(err, "failed to parse SiteURL from config") + return err } - return UpdateAssetsSubpath(u.Path) + return UpdateAssetsSubpath(subpath) +} + +func GetSubpathFromConfig(config *model.Config) (string, error) { + if config == nil { + return "", errors.New("no config provided") + } else if config.ServiceSettings.SiteURL == nil { + return "/", nil + } + + u, err := url.Parse(*config.ServiceSettings.SiteURL) + if err != nil { + return "", errors.Wrap(err, "failed to parse SiteURL from config") + } + + if u.Path == "" { + return "/", nil + } + + return path.Clean(u.Path), nil } diff --git a/web/helpers_test.go b/utils/subpath_test.go similarity index 81% rename from web/helpers_test.go rename to utils/subpath_test.go index 4e6a7ff6ab..ee518d5f6a 100644 --- a/web/helpers_test.go +++ b/utils/subpath_test.go @@ -1,4 +1,181 @@ -package web_test +package utils_test + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/model" + "github.com/mattermost/mattermost-server/utils" +) + +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 = utils.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 := utils.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)) + + }) + } + }) +} + +func TestGetSubpathFromConfig(t *testing.T) { + sToP := func(s string) *string { + return &s + } + + testCases := []struct { + Description string + SiteURL *string + ExpectedError bool + ExpectedSubpath string + }{ + { + "empty SiteURL", + sToP(""), + false, + "/", + }, + { + "invalid SiteURL", + sToP("cache_object:foo/bar"), + true, + "", + }, + { + "nil SiteURL", + nil, + false, + "/", + }, + { + "no trailing slash", + sToP("http://localhost:8065"), + false, + "/", + }, + { + "trailing slash", + sToP("http://localhost:8065/"), + false, + "/", + }, + { + "subpath, no trailing slash", + sToP("http://localhost:8065/subpath"), + false, + "/subpath", + }, + { + "trailing slash", + sToP("http://localhost:8065/subpath/"), + false, + "/subpath", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.Description, func(t *testing.T) { + config := &model.Config{ + ServiceSettings: model.ServiceSettings{ + SiteURL: testCase.SiteURL, + }, + } + + subpath, err := utils.GetSubpathFromConfig(config) + if testCase.ExpectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + require.Equal(t, testCase.ExpectedSubpath, subpath) + }) + } +} const baseRootHtml = `
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.