diff --git a/api4/system.go b/api4/system.go index bf477f75d3..6c2c7a390f 100644 --- a/api4/system.go +++ b/api4/system.go @@ -50,6 +50,7 @@ func (api *API) InitSystem() { api.BaseRoutes.APIRoot.Handle("/logs", api.APIHandler(postLog)).Methods("POST") api.BaseRoutes.APIRoot.Handle("/analytics/old", api.APISessionRequired(getAnalytics)).Methods("GET") + api.BaseRoutes.APIRoot.Handle("/latest_version", api.APISessionRequired(getLatestVersion)).Methods("GET") api.BaseRoutes.APIRoot.Handle("/redirect_location", api.APISessionRequiredTrustRequester(getRedirectLocation)).Methods("GET") @@ -402,6 +403,27 @@ func getAnalytics(c *Context, w http.ResponseWriter, r *http.Request) { } } +func getLatestVersion(c *Context, w http.ResponseWriter, r *http.Request) { + if *c.App.Config().ExperimentalSettings.RestrictSystemAdmin { + c.Err = model.NewAppError("latestVersion", "api.restricted_system_admin", nil, "", http.StatusForbidden) + return + } + + resp, err := c.App.GetLatestVersion("https://api.github.com/repos/mattermost/mattermost-server/releases/latest") + if err != nil { + c.Err = err + return + } + + b, jsonErr := json.Marshal(resp) + if jsonErr != nil { + c.Logger.Warn("Unable to marshal JSON for latest version.", mlog.Err(jsonErr)) + w.WriteHeader(http.StatusInternalServerError) + } + + w.Write(b) +} + func getSupportedTimezones(c *Context, w http.ResponseWriter, r *http.Request) { supportedTimezones := c.App.Timezones().GetSupported() if supportedTimezones == nil { diff --git a/app/admin.go b/app/admin.go index 4b3fb3bf34..722a5b4369 100644 --- a/app/admin.go +++ b/app/admin.go @@ -4,6 +4,7 @@ package app import ( + "encoding/json" "fmt" "io" "io/ioutil" @@ -14,11 +15,16 @@ import ( "github.com/mattermost/mattermost-server/v6/config" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/services/cache" "github.com/mattermost/mattermost-server/v6/shared/i18n" "github.com/mattermost/mattermost-server/v6/shared/mail" "github.com/mattermost/mattermost-server/v6/shared/mlog" ) +var latestVersionCache = cache.NewLRU(cache.LRUOptions{ + Size: 1, +}) + func (s *Server) GetLogs(page, perPage int) ([]string, *model.AppError) { var lines []string @@ -241,3 +247,43 @@ func (s *Server) serverBusyStateChanged(sbs *model.ServerBusyState) { mlog.Info("server busy state cleared via cluster event - non-critical services enabled") } } + +func (a *App) GetLatestVersion(latestVersionUrl string) (*model.GithubReleaseInfo, *model.AppError) { + var cachedLatestVersion *model.GithubReleaseInfo + if cacheErr := latestVersionCache.Get("latest_version_cache", &cachedLatestVersion); cacheErr == nil { + return cachedLatestVersion, nil + } + + res, err := http.Get(latestVersionUrl) + if err != nil { + return nil, model.NewAppError("GetLatestVersion", "app.admin.latest_version_external_error.failure", nil, "", http.StatusInternalServerError) + } + + defer res.Body.Close() + + responseData, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, model.NewAppError("GetLatestVersion", "app.admin.latest_version_read_all.failure", nil, "", http.StatusInternalServerError) + } + + var releaseInfoResponse *model.GithubReleaseInfo + err = json.Unmarshal(responseData, &releaseInfoResponse) + if err != nil { + return nil, model.NewAppError("GetLatestVersion", "app.admin.latest_version_unmarshal.failure", nil, "", http.StatusInternalServerError) + } + + if validErr := releaseInfoResponse.IsValid(); validErr != nil { + return nil, model.NewAppError("GetLatestVersion", "app.admin.latest_version_external_error.failure", nil, "", http.StatusInternalServerError) + } + + err = latestVersionCache.Set("latest_version_cache", releaseInfoResponse) + if err != nil { + return nil, model.NewAppError("GetLatestVersion", "app.admin.latest_version_set_cache.failure", nil, "", http.StatusInternalServerError) + } + + return releaseInfoResponse, nil +} + +func (a *App) ClearLatestVersionCache() { + latestVersionCache.Remove("latest_version_cache") +} diff --git a/app/admin_test.go b/app/admin_test.go new file mode 100644 index 0000000000..e6f32ae5bf --- /dev/null +++ b/app/admin_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/v6/model" +) + +func TestGetLatestVersion(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + version := &model.GithubReleaseInfo{ + Id: 57117096, + TagName: "v6.3.0", + Name: "v6.3.0", + CreatedAt: "2022-01-13T14:19:44Z", + PublishedAt: "2022-01-14T13:45:09Z", + Body: "Mattermost Platform Release v6.3.0", + Url: "https://github.com/mattermost/mattermost-server/releases/tag/v6.3.0", + } + + validJSON, jsonErr := json.Marshal(version) + require.NoError(t, jsonErr) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(validJSON) + })) + defer ts.Close() + + t.Run("get latest mm version happy path", func(t *testing.T) { + _, err := th.App.GetLatestVersion(ts.URL) + require.Nil(t, err) + }) + + t.Run("get latest mm version from cache", func(t *testing.T) { + th.App.ClearLatestVersionCache() + originalResult, err := th.App.GetLatestVersion(ts.URL) + require.Nil(t, err) + + // Call same function but mock the GET request to return a different result. + // We are hoping the function will use the cache instead of making the GET request + v := &model.GithubReleaseInfo{ + Id: 57117096, + TagName: "v6.3.1", + Name: "v6.3.1", + CreatedAt: "2022-01-13T14:19:44Z", + PublishedAt: "2022-01-14T13:45:09Z", + Body: "Mattermost Platform Release v6.3.0", + Url: "https://github.com/mattermost/mattermost-server/releases/tag/v6.3.0", + } + + updatedJSON, jsonErr := json.Marshal(v) + require.NoError(t, jsonErr) + + updatedServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(updatedJSON) + })) + defer ts.Close() + + cachedResult, err := th.App.GetLatestVersion(updatedServer.URL) + require.Nil(t, err) + + require.Equal(t, originalResult.TagName, cachedResult.TagName, "did not get cached result") + }) + + t.Run("get latest mm version error from external", func(t *testing.T) { + th.App.ClearLatestVersionCache() + errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(` + { + "message": "internal server error" + } + `)) + })) + defer ts.Close() + + _, err := th.App.GetLatestVersion(errorServer.URL) + require.NotNil(t, err) + require.Equal(t, "app.admin.latest_version_external_error.failure", err.Id) + }) +} diff --git a/app/app_iface.go b/app/app_iface.go index 272358df7d..e179ecd245 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -425,6 +425,7 @@ type AppIface interface { CheckUserPreflightAuthenticationCriteria(user *model.User, mfaToken string) *model.AppError CheckWebConn(userID, connectionID string) *CheckConnResult ClearChannelMembersCache(channelID string) + ClearLatestVersionCache() ClearSessionCacheForAllUsers() ClearSessionCacheForAllUsersSkipClusterSend() ClearSessionCacheForUser(userID string) @@ -630,6 +631,7 @@ type AppIface interface { GetJobsByTypesPage(jobType []string, page int, perPage int) ([]*model.Job, *model.AppError) GetJobsPage(page int, perPage int) ([]*model.Job, *model.AppError) GetLatestTermsOfService() (*model.TermsOfService, *model.AppError) + GetLatestVersion(latestVersionUrl string) (*model.GithubReleaseInfo, *model.AppError) GetLogs(page, perPage int) ([]string, *model.AppError) GetLogsSkipSend(page, perPage int) ([]string, *model.AppError) GetMemberCountsByGroup(ctx context.Context, channelID string, includeTimezones bool) ([]*model.ChannelMemberCountByGroup, *model.AppError) diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 367a6af60b..3c8ebd70a8 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -1447,6 +1447,21 @@ func (a *OpenTracingAppLayer) ClearChannelMembersCache(channelID string) { a.app.ClearChannelMembersCache(channelID) } +func (a *OpenTracingAppLayer) ClearLatestVersionCache() { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ClearLatestVersionCache") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + a.app.ClearLatestVersionCache() +} + func (a *OpenTracingAppLayer) ClearSessionCacheForAllUsers() { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ClearSessionCacheForAllUsers") @@ -6593,6 +6608,28 @@ func (a *OpenTracingAppLayer) GetLatestTermsOfService() (*model.TermsOfService, return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetLatestVersion(latestVersionUrl string) (*model.GithubReleaseInfo, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetLatestVersion") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetLatestVersion(latestVersionUrl) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) GetLdapGroup(ldapGroupID string) (*model.Group, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetLdapGroup") diff --git a/i18n/en.json b/i18n/en.json index 1df79b9daf..d929d05f13 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -4331,6 +4331,22 @@ "id": "api.websocket_handler.server_busy.app_error", "translation": "Server is busy, non-critical services are temporarily unavailable." }, + { + "id": "app.admin.latest_version_external_error.failure", + "translation": " " + }, + { + "id": "app.admin.latest_version_read_all.failure", + "translation": " " + }, + { + "id": "app.admin.latest_version_set_cache.failure", + "translation": " " + }, + { + "id": "app.admin.latest_version_unmarshal.failure", + "translation": " " + }, { "id": "app.admin.saml.failure_decode_metadata_xml_from_idp.app_error", "translation": "Could not decode the XML metadata information received from the Identity Provider." @@ -8379,6 +8395,10 @@ "id": "model.file_info.is_valid.user_id.app_error", "translation": "Invalid value for user_id." }, + { + "id": "model.github_release_info.is_valid.id.app_error", + "translation": " " + }, { "id": "model.group.create_at.app_error", "translation": "invalid create at property for group." diff --git a/model/github_release.go b/model/github_release.go new file mode 100644 index 0000000000..75cc0a5fda --- /dev/null +++ b/model/github_release.go @@ -0,0 +1,26 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "net/http" +) + +type GithubReleaseInfo struct { + Id int `json:"id"` + TagName string `json:"tag_name"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` + PublishedAt string `json:"published_at"` + Body string `json:"body"` + Url string `json:"html_url"` +} + +func (g *GithubReleaseInfo) IsValid() *AppError { + if g.Id == 0 { + return NewAppError("GithubReleaseInfo.IsValid", "model.github_release_info.is_valid.id.app_error", nil, "", http.StatusInternalServerError) + } + + return nil +}