diff --git a/Makefile b/Makefile index f97b3a173a..ede4f447d0 100644 --- a/Makefile +++ b/Makefile @@ -149,7 +149,7 @@ TEMPLATES_DIR=templates PLUGIN_PACKAGES ?= mattermost-plugin-antivirus-v0.1.2 PLUGIN_PACKAGES += mattermost-plugin-autolink-v1.2.2 PLUGIN_PACKAGES += mattermost-plugin-aws-SNS-v1.2.0 -PLUGIN_PACKAGES += mattermost-plugin-calls-v0.10.0 +PLUGIN_PACKAGES += mattermost-plugin-calls-v0.11.0 PLUGIN_PACKAGES += mattermost-plugin-channel-export-v1.0.0 PLUGIN_PACKAGES += mattermost-plugin-confluence-v1.3.0 PLUGIN_PACKAGES += mattermost-plugin-custom-attributes-v1.3.1 @@ -159,11 +159,11 @@ PLUGIN_PACKAGES += mattermost-plugin-playbooks-v1.34.0 PLUGIN_PACKAGES += mattermost-plugin-jenkins-v1.1.0 PLUGIN_PACKAGES += mattermost-plugin-jira-v3.2.2 PLUGIN_PACKAGES += mattermost-plugin-jitsi-v2.0.1 -PLUGIN_PACKAGES += mattermost-plugin-nps-v1.3.0 +PLUGIN_PACKAGES += mattermost-plugin-nps-v1.3.1 PLUGIN_PACKAGES += mattermost-plugin-todo-v0.6.1 PLUGIN_PACKAGES += mattermost-plugin-welcomebot-v1.2.0 PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.6.0 -PLUGIN_PACKAGES += focalboard-v7.5.2 +PLUGIN_PACKAGES += focalboard-v7.5.4 PLUGIN_PACKAGES += mattermost-plugin-apps-v1.1.0 # Prepares the enterprise build if exists. The IGNORE stuff is a hack to get the Makefile to execute the commands outside a target diff --git a/api4/cloud.go b/api4/cloud.go index 02fab0752a..2499164fac 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -80,7 +80,6 @@ func getSubscription(c *Context, w http.ResponseWriter, r *http.Request) { Seats: 0, Status: "", DNS: "", - IsPaidTier: "", LastInvoice: &model.Invoice{}, DelinquentSince: subscription.DelinquentSince, } diff --git a/api4/cloud_test.go b/api4/cloud_test.go index f44176656c..75bf89f631 100644 --- a/api4/cloud_test.go +++ b/api4/cloud_test.go @@ -124,7 +124,6 @@ func Test_GetSubscription(t *testing.T) { Seats: 10, IsFreeTrial: "true", DNS: "some.dns.server", - IsPaidTier: "false", TrialEndAt: 2000000000, LastInvoice: &model.Invoice{}, DelinquentSince: &deliquencySince, @@ -141,7 +140,6 @@ func Test_GetSubscription(t *testing.T) { Seats: 0, IsFreeTrial: "true", DNS: "", - IsPaidTier: "", TrialEndAt: 2000000000, LastInvoice: &model.Invoice{}, DelinquentSince: &deliquencySince, @@ -209,7 +207,6 @@ func Test_requestTrial(t *testing.T) { CreateAt: 1000000000, Seats: 10, DNS: "some.dns.server", - IsPaidTier: "false", } newValidBusinessEmail := model.StartCloudTrialRequest{Email: ""} diff --git a/api4/group.go b/api4/group.go index 72c78e7179..59b9ad12c1 100644 --- a/api4/group.go +++ b/api4/group.go @@ -1185,8 +1185,8 @@ func restoreGroup(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToGroup(*c.AppContext.Session(), c.Params.GroupId, model.PermissionDeleteCustomGroup) { - c.SetPermissionError(model.PermissionDeleteCustomGroup) + if !c.App.SessionHasPermissionToGroup(*c.AppContext.Session(), c.Params.GroupId, model.PermissionRestoreCustomGroup) { + c.SetPermissionError(model.PermissionRestoreCustomGroup) return } diff --git a/api4/group_test.go b/api4/group_test.go index a612291d8c..7fdf38147e 100644 --- a/api4/group_test.go +++ b/api4/group_test.go @@ -231,7 +231,13 @@ func TestUndeleteGroup(t *testing.T) { _, response, err := th.Client.DeleteGroup(validGroup.Id) require.NoError(t, err) CheckOKStatus(t, response) + th.RemovePermissionFromRole(model.PermissionRestoreCustomGroup.Id, model.SystemUserRoleId) + // shouldn't allow restoring unless user has required permission + _, response, err = th.Client.RestoreGroup(validGroup.Id, "") + require.Error(t, err) + CheckForbiddenStatus(t, response) + th.AddPermissionToRole(model.PermissionRestoreCustomGroup.Id, model.SystemUserRoleId) _, response, err = th.Client.RestoreGroup(validGroup.Id, "") require.NoError(t, err) CheckOKStatus(t, response) diff --git a/api4/hosted_customer.go b/api4/hosted_customer.go index 6e696967c6..ae11514ae4 100644 --- a/api4/hosted_customer.go +++ b/api4/hosted_customer.go @@ -4,18 +4,35 @@ package api4 import ( + "bytes" + "encoding/binary" "encoding/json" + "fmt" + "io" "net/http" + "reflect" + "time" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/mlog" + "github.com/mattermost/mattermost-server/v6/utils" ) // APIs for self-hosted workspaces to communicate with the backing customer & payments system. // Endpoints for cloud installations should not go in this file. func (api *API) InitHostedCustomer() { - + // POST /api/v4/hosted_customer/available + api.BaseRoutes.HostedCustomer.Handle("/signup_available", api.APISessionRequired(handleSignupAvailable)).Methods("GET") // POST /api/v4/hosted_customer/bootstrap api.BaseRoutes.HostedCustomer.Handle("/bootstrap", api.APISessionRequired(selfHostedBootstrap)).Methods("POST") + // POST /api/v4/hosted_customer/customer + api.BaseRoutes.HostedCustomer.Handle("/customer", api.APISessionRequired(selfHostedCustomer)).Methods("POST") + // POST /api/v4/hosted_customer/confirm + api.BaseRoutes.HostedCustomer.Handle("/confirm", api.APISessionRequired(selfHostedConfirm)).Methods("POST") + // GET /api/v4/hosted_customer/invoices + api.BaseRoutes.HostedCustomer.Handle("/invoices", api.APISessionRequired(selfHostedInvoices)).Methods("GET") + // GET /api/v4/hosted_customer/invoices/{invoice_id:in_[A-Za-z0-9]+}/pdf + api.BaseRoutes.HostedCustomer.Handle("/invoices/{invoice_id:in_[A-Za-z0-9]+}/pdf", api.APISessionRequired(selfHostedInvoicePDF)).Methods("GET") } func ensureSelfHostedAdmin(c *Context, where string) { @@ -32,21 +49,22 @@ func ensureSelfHostedAdmin(c *Context, where string) { } } -func checkSelfHostedFirstTimePurchaseEnabled(c *Context) bool { +func checkSelfHostedPurchaseEnabled(c *Context) bool { config := c.App.Config() if config == nil { return false } - enabled := config.ServiceSettings.SelfHostedFirstTimePurchase + enabled := config.ServiceSettings.SelfHostedPurchase return enabled != nil && *enabled } func selfHostedBootstrap(c *Context, w http.ResponseWriter, r *http.Request) { - where := "Api4.selfHostedBootstrap" - if !checkSelfHostedFirstTimePurchaseEnabled(c) { + const where = "Api4.selfHostedBootstrap" + if !checkSelfHostedPurchaseEnabled(c) { c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) return } + reset := r.URL.Query().Get("reset") == "true" ensureSelfHostedAdmin(c, where) if c.Err != nil { return @@ -58,7 +76,7 @@ func selfHostedBootstrap(c *Context, w http.ResponseWriter, r *http.Request) { return } - signupProgress, err := c.App.Cloud().BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: user.Email}) + signupProgress, err := c.App.Cloud().BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: user.Email, Reset: reset}) if err != nil { c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError) return @@ -71,3 +89,186 @@ func selfHostedBootstrap(c *Context, w http.ResponseWriter, r *http.Request) { w.Write(json) } + +func selfHostedCustomer(c *Context, w http.ResponseWriter, r *http.Request) { + const where = "Api4.selfHostedCustomer" + ensureSelfHostedAdmin(c, where) + if c.Err != nil { + return + } + if !checkSelfHostedPurchaseEnabled(c) { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) + return + } + + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) + return + } + + var form *model.SelfHostedCustomerForm + if err = json.Unmarshal(bodyBytes, &form); err != nil { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) + return + } + + user, userErr := c.App.GetUser(c.AppContext.Session().UserId) + if userErr != nil { + c.Err = userErr + return + } + customerResponse, err := c.App.Cloud().CreateCustomerSelfHostedSignup(*form, user.Email) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + json, err := json.Marshal(customerResponse) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + w.Write(json) +} + +func selfHostedConfirm(c *Context, w http.ResponseWriter, r *http.Request) { + const where = "Api4.selfHostedConfirm" + ensureSelfHostedAdmin(c, where) + if c.Err != nil { + return + } + if !checkSelfHostedPurchaseEnabled(c) { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) + return + } + + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) + return + } + + var confirm model.SelfHostedConfirmPaymentMethodRequest + err = json.Unmarshal(bodyBytes, &confirm) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err) + return + } + + user, userErr := c.App.GetUser(c.AppContext.Session().UserId) + if userErr != nil { + c.Err = userErr + return + } + confirmResponse, err := c.App.Cloud().ConfirmSelfHostedSignup(confirm, user.Email) + if err != nil { + if confirmResponse != nil { + c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) + } + + if err.Error() == fmt.Sprintf("%d", http.StatusUnprocessableEntity) { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusUnprocessableEntity).Wrap(err) + return + } + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + license, err := c.App.Srv().Platform().SaveLicense([]byte(confirmResponse.License)) + // dealing with an AppError + if !(reflect.ValueOf(err).Kind() == reflect.Ptr && reflect.ValueOf(err).IsNil()) { + if confirmResponse != nil { + c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) + } + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + clientResponse, err := json.Marshal(model.SelfHostedSignupConfirmClientResponse{ + License: utils.GetClientLicense(license), + Progress: confirmResponse.Progress, + }) + if err != nil { + if confirmResponse != nil { + c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) + } + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + go func() { + err := c.App.Cloud().ConfirmSelfHostedSignupLicenseApplication() + if err != nil { + c.Logger.Warn("Unable to confirm license application", mlog.Err(err)) + } + }() + + _, _ = w.Write(clientResponse) +} + +func handleSignupAvailable(c *Context, w http.ResponseWriter, r *http.Request) { + const where = "Api4.handleSignupAvailable" + ensureSelfHostedAdmin(c, where) + if c.Err != nil { + return + } + if !checkSelfHostedPurchaseEnabled(c) { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) + return + } + if err := c.App.Cloud().SelfHostedSignupAvailable(); err != nil { + c.Err = model.NewAppError(where, "api.server.hosted_signup_unavailable.error", nil, "", http.StatusNotImplemented) + return + } + + ReturnStatusOK(w) +} + +func selfHostedInvoices(c *Context, w http.ResponseWriter, r *http.Request) { + const where = "Api4.selfHostedInvoices" + ensureSelfHostedAdmin(c, where) + if c.Err != nil { + return + } + + invoices, err := c.App.Cloud().GetSelfHostedInvoices() + + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + json, err := json.Marshal(invoices) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + w.Write(json) +} + +func selfHostedInvoicePDF(c *Context, w http.ResponseWriter, r *http.Request) { + const where = "Api4.selfHostedInvoicePDF" + ensureSelfHostedAdmin(c, where) + if c.Err != nil { + return + } + + pdfData, filename, appErr := c.App.Cloud().GetSelfHostedInvoicePDF(c.Params.InvoiceId) + if appErr != nil { + c.Err = model.NewAppError("Api4.getSubscriptionInvoicePDF", "api.cloud.request_error", nil, appErr.Error(), http.StatusInternalServerError) + return + } + + writeFileResponse( + filename, + "application/pdf", + int64(binary.Size(pdfData)), + time.Now(), + *c.App.Config().ServiceSettings.WebserverMode, + bytes.NewReader(pdfData), + false, + w, + r, + ) +} diff --git a/api4/hosted_customer_test.go b/api4/hosted_customer_test.go index 6eff1e922d..dd895c5675 100644 --- a/api4/hosted_customer_test.go +++ b/api4/hosted_customer_test.go @@ -27,7 +27,7 @@ func TestSelfHostedBootstrap(t *testing.T) { os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "false") defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE") - th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedFirstTimePurchase = &valFalse }) + th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedPurchase = &valFalse }) th.App.ReloadConfig() _, r, err := th.Client.BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) @@ -45,7 +45,7 @@ func TestSelfHostedBootstrap(t *testing.T) { th.App.Srv().SetLicense(model.NewTestLicense("cloud")) os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "true") defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE") - th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedFirstTimePurchase = &valTrue }) + th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedPurchase = &valTrue }) th.App.ReloadConfig() _, r, err := th.Client.BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) @@ -62,7 +62,7 @@ func TestSelfHostedBootstrap(t *testing.T) { os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "true") defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE") - th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedFirstTimePurchase = &valTrue }) + th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedPurchase = &valTrue }) th.App.ReloadConfig() _, r, err := th.Client.BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) @@ -79,7 +79,7 @@ func TestSelfHostedBootstrap(t *testing.T) { os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "true") defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE") - th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedFirstTimePurchase = &valTrue }) + th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedPurchase = &valTrue }) th.App.ReloadConfig() cloud := mocks.CloudInterface{} diff --git a/api4/post.go b/api4/post.go index ff707c178c..34bcf21c2b 100644 --- a/api4/post.go +++ b/api4/post.go @@ -802,7 +802,8 @@ func patchPost(c *Context, w http.ResponseWriter, r *http.Request) { } auditRec := c.MakeAuditRecord("patchPost", audit.Fail) - auditRec.AddEventParameter("patch", post) + auditRec.AddEventParameter("id", c.Params.PostId) + auditRec.AddEventParameter("patch", post.Auditable()) defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) // Updating the file_ids of a post is not a supported operation and will be ignored diff --git a/api4/remote_cluster.go b/api4/remote_cluster.go index 0fe0be57be..c70d767cd3 100644 --- a/api4/remote_cluster.go +++ b/api4/remote_cluster.go @@ -9,6 +9,7 @@ import ( "net/http" "time" + "github.com/mattermost/mattermost-server/v6/app" "github.com/mattermost/mattermost-server/v6/audit" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/services/remotecluster" @@ -194,7 +195,8 @@ func uploadRemoteData(c *Context, w http.ResponseWriter, r *http.Request) { defer c.LogAuditRec(auditRec) auditRec.AddEventParameter("upload_id", c.Params.UploadId) - us, err := c.App.GetUploadSession(c.Params.UploadId) + c.AppContext.SetContext(app.WithMaster(c.AppContext.Context())) + us, err := c.App.GetUploadSession(c.AppContext, c.Params.UploadId) if err != nil { c.Err = err return diff --git a/api4/upload.go b/api4/upload.go index a89bc1149c..37f5a1bb01 100644 --- a/api4/upload.go +++ b/api4/upload.go @@ -10,6 +10,7 @@ import ( "mime/multipart" "net/http" + "github.com/mattermost/mattermost-server/v6/app" "github.com/mattermost/mattermost-server/v6/audit" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/shared/mlog" @@ -91,7 +92,7 @@ func getUpload(c *Context, w http.ResponseWriter, r *http.Request) { return } - us, err := c.App.GetUploadSession(c.Params.UploadId) + us, err := c.App.GetUploadSession(c.AppContext, c.Params.UploadId) if err != nil { c.Err = err return @@ -123,7 +124,8 @@ func uploadData(c *Context, w http.ResponseWriter, r *http.Request) { defer c.LogAuditRec(auditRec) auditRec.AddEventParameter("upload_id", c.Params.UploadId) - us, err := c.App.GetUploadSession(c.Params.UploadId) + c.AppContext.SetContext(app.WithMaster(c.AppContext.Context())) + us, err := c.App.GetUploadSession(c.AppContext, c.Params.UploadId) if err != nil { c.Err = err return diff --git a/api4/user.go b/api4/user.go index 15af912ab7..a35d1d6597 100644 --- a/api4/user.go +++ b/api4/user.go @@ -662,13 +662,14 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { return } - if sort != "" && sort != "last_activity_at" && sort != "create_at" && sort != "status" && sort != "admin" { + if sort != "" && sort != "last_activity_at" && sort != "create_at" && sort != "status" && sort != "admin" && sort != "display_name" { c.SetInvalidURLParam("sort") return } // Currently only supports sorting on a team // or sort="status" on inChannelId + // or sort="display_name" on inGroupId if (sort == "last_activity_at" || sort == "create_at") && (inTeamId == "" || notInTeamId != "" || inChannelId != "" || notInChannelId != "" || withoutTeam != "" || inGroupId != "" || notInGroupId != "") { c.SetInvalidURLParam("sort") return @@ -681,6 +682,10 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { c.SetInvalidURLParam("sort") return } + if sort == "display_name" && (inGroupId == "" || notInGroupId != "" || inTeamId != "" || notInTeamId != "" || inChannelId != "" || notInChannelId != "" || withoutTeam != "") { + c.SetInvalidURLParam("sort") + return + } var ( withoutTeamBool, _ = strconv.ParseBool(withoutTeam) @@ -693,14 +698,44 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { c.SetInvalidURLParam("inactive") } + roleNamesAll := []string{} + // MM-47378: validate 'role' related parameters + if role != "" || rolesString != "" || channelRolesString != "" || teamRolesString != "" { + // fetch all role names + rolesAll, err := c.App.GetAllRoles() + if err != nil { + c.Err = model.NewAppError("Api4.getUsers", "api.user.get_users.validation.app_error", nil, "Error fetching roles during validation.", http.StatusBadRequest) + return + } + for _, role := range rolesAll { + roleNamesAll = append(roleNamesAll, role.Name) + } + } roles := []string{} var rolesValid bool + if role != "" { + roles, rolesValid = model.CleanRoleNames([]string{role}) + if !rolesValid { + c.SetInvalidParam("role") + return + } + roleValid := utils.StringInSlice(role, roleNamesAll) + if !roleValid { + c.SetInvalidParam("role") + return + } + } if rolesString != "" { roles, rolesValid = model.CleanRoleNames(strings.Split(rolesString, ",")) if !rolesValid { c.SetInvalidParam("roles") return } + validRoleNames := utils.StringArrayIntersection(roleNamesAll, roles) + if len(validRoleNames) != len(roles) { + c.SetInvalidParam("roles") + return + } } channelRoles := []string{} if channelRolesString != "" && inChannelId != "" { @@ -709,6 +744,11 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { c.SetInvalidParam("channelRoles") return } + validRoleNames := utils.StringArrayIntersection(roleNamesAll, channelRoles) + if len(validRoleNames) != len(channelRoles) { + c.SetInvalidParam("channelRoles") + return + } } teamRoles := []string{} if teamRolesString != "" && inTeamId != "" { @@ -717,6 +757,11 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { c.SetInvalidParam("teamRoles") return } + validRoleNames := utils.StringArrayIntersection(roleNamesAll, teamRoles) + if len(validRoleNames) != len(teamRoles) { + c.SetInvalidParam("teamRoles") + return + } } restrictions, appErr := c.App.GetViewUsersRestrictions(c.AppContext.Session().UserId) @@ -829,10 +874,18 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { return } - profiles, _, appErr = c.App.GetGroupMemberUsersPage(inGroupId, c.Params.Page, c.Params.PerPage, userGetOptions.ViewRestrictions) - if appErr != nil { - c.Err = appErr - return + if sort == "display_name" { + var user *model.User + + user, appErr = c.App.GetUser(c.AppContext.Session().UserId) + if appErr != nil { + c.Err = appErr + return + } + + profiles, _, appErr = c.App.GetGroupMemberUsersSortedPage(inGroupId, c.Params.Page, c.Params.PerPage, userGetOptions.ViewRestrictions, c.App.GetNotificationNameFormat(user)) + } else { + profiles, _, appErr = c.App.GetGroupMemberUsersPage(inGroupId, c.Params.Page, c.Params.PerPage, userGetOptions.ViewRestrictions) } } else if notInGroupId != "" { appErr = requireGroupAccess(c, notInGroupId) diff --git a/api4/user_local.go b/api4/user_local.go index 50a451ea96..79578f2484 100644 --- a/api4/user_local.go +++ b/api4/user_local.go @@ -7,11 +7,13 @@ import ( "encoding/json" "net/http" "strconv" + "strings" "github.com/mattermost/mattermost-server/v6/audit" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/shared/mlog" "github.com/mattermost/mattermost-server/v6/store" + "github.com/mattermost/mattermost-server/v6/utils" ) func (api *API) InitUserLocal() { @@ -56,7 +58,78 @@ func localGetUsers(c *Context, w http.ResponseWriter, r *http.Request) { active := r.URL.Query().Get("active") inactive := r.URL.Query().Get("inactive") role := r.URL.Query().Get("role") + rolesString := r.URL.Query().Get("roles") + channelRolesString := r.URL.Query().Get("channel_roles") + teamRolesString := r.URL.Query().Get("team_roles") sort := r.URL.Query().Get("sort") + roleNamesAll := []string{} + // MM-47378: validate 'role' related parameters + if role != "" || rolesString != "" || channelRolesString != "" || teamRolesString != "" { + // fetch all role names + rolesAll, err := c.App.GetAllRoles() + if err != nil { + c.Err = model.NewAppError("Api4.getUsers", "api.user.get_users.validation.app_error", nil, "Error fetching roles during validation.", http.StatusBadRequest) + return + } + for _, role := range rolesAll { + roleNamesAll = append(roleNamesAll, role.Name) + } + } + + var roles []string + var rolesValid bool + + if role != "" { + _, rolesValid = model.CleanRoleNames([]string{role}) + if !rolesValid { + c.SetInvalidParam("role") + return + } + roleValid := utils.StringInSlice(role, roleNamesAll) + if !roleValid { + c.SetInvalidParam("role") + return + } + } + + if rolesString != "" { + roles, rolesValid = model.CleanRoleNames(strings.Split(rolesString, ",")) + if !rolesValid { + c.SetInvalidParam("roles") + return + } + validRoleNames := utils.StringArrayIntersection(roleNamesAll, roles) + if len(validRoleNames) != len(roles) { + c.SetInvalidParam("roles") + return + } + } + var channelRoles []string + if channelRolesString != "" && inChannelId != "" { + channelRoles, rolesValid = model.CleanRoleNames(strings.Split(channelRolesString, ",")) + if !rolesValid { + c.SetInvalidParam("channelRoles") + return + } + validRoleNames := utils.StringArrayIntersection(roleNamesAll, channelRoles) + if len(validRoleNames) != len(channelRoles) { + c.SetInvalidParam("channelRoles") + return + } + } + var teamRoles []string + if teamRolesString != "" && inTeamId != "" { + teamRoles, rolesValid = model.CleanRoleNames(strings.Split(teamRolesString, ",")) + if !rolesValid { + c.SetInvalidParam("teamRoles") + return + } + validRoleNames := utils.StringArrayIntersection(roleNamesAll, teamRoles) + if len(validRoleNames) != len(teamRoles) { + c.SetInvalidParam("teamRoles") + return + } + } if notInChannelId != "" && inTeamId == "" { c.SetInvalidURLParam("team_id") diff --git a/api4/user_test.go b/api4/user_test.go index 17195f7d6d..affd768fd7 100644 --- a/api4/user_test.go +++ b/api4/user_test.go @@ -2410,6 +2410,20 @@ func TestGetUsers(t *testing.T) { // Check default params for page and per_page _, err = client.DoAPIGet("/users", "") require.NoError(t, err) + + // Check role params validity + _, _, err = client.GetUsersWithCustomQueryParameters(0, 5, "in_channel=random_channel_id&channel_roles=random_role_doesnt_exist", "") + require.Error(t, err) + require.Equal(t, err.Error(), ": Invalid or missing channelRoles in request body.") + _, _, err = client.GetUsersWithCustomQueryParameters(0, 5, "in_team=random_channel_id&team_roles=random_role_doesnt_exist", "") + require.Error(t, err) + require.Equal(t, err.Error(), ": Invalid or missing teamRoles in request body.") + _, _, err = client.GetUsersWithCustomQueryParameters(0, 5, "roles=random_role_doesnt_exist%2Csystem_user", "") + require.Error(t, err) + require.Equal(t, err.Error(), ": Invalid or missing roles in request body.") + _, _, err = client.GetUsersWithCustomQueryParameters(0, 5, "role=random_role_doesnt_exist", "") + require.Error(t, err) + require.Equal(t, err.Error(), ": Invalid or missing role in request body.") }) th.Client.Logout() @@ -2823,6 +2837,64 @@ func TestGetUsersInGroup(t *testing.T) { } +func TestGetUsersInGroupByDisplayName(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + id := model.NewId() + group, appErr := th.App.CreateGroup(&model.Group{ + DisplayName: "dn-foo_" + id, + Name: model.NewString("name" + id), + Source: model.GroupSourceLdap, + Description: "description_" + id, + RemoteId: model.NewString(model.NewId()), + }) + assert.Nil(t, appErr) + + user1, err := th.App.CreateUser(th.Context, &model.User{Email: th.GenerateTestEmail(), Nickname: "aaa", Password: "test-password-1", Username: "zzz", Roles: model.SystemUserRoleId}) + assert.Nil(t, err) + + user2, err := th.App.CreateUser(th.Context, &model.User{Email: th.GenerateTestEmail(), Password: "test-password-2", Username: "bbb", Roles: model.SystemUserRoleId}) + assert.Nil(t, err) + + _, err = th.App.UpsertGroupMember(group.Id, user1.Id) + assert.Nil(t, err) + _, err = th.App.UpsertGroupMember(group.Id, user2.Id) + assert.Nil(t, err) + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PrivacySettings.ShowFullName = true + }) + + preference := model.Preference{ + UserId: th.SystemAdminUser.Id, + Category: model.PreferenceCategoryDisplaySettings, + Name: model.PreferenceNameNameFormat, + Value: model.ShowUsername, + } + + err = th.App.UpdatePreferences(th.SystemAdminUser.Id, model.Preferences{preference}) + assert.Nil(t, err) + + t.Run("Returns users in group in right order for username", func(t *testing.T) { + users, _, err := th.SystemAdminClient.GetUsersInGroupByDisplayName(group.Id, 0, 1, "") + require.NoError(t, err) + assert.Equal(t, users[0].Id, user2.Id) + }) + + preference.Value = model.ShowNicknameFullName + err = th.App.UpdatePreferences(th.SystemAdminUser.Id, model.Preferences{preference}) + assert.Nil(t, err) + + t.Run("Returns users in group in right order for nickname", func(t *testing.T) { + users, _, err := th.SystemAdminClient.GetUsersInGroupByDisplayName(group.Id, 0, 1, "") + require.NoError(t, err) + assert.Equal(t, users[0].Id, user1.Id) + }) + +} + func TestUpdateUserMfa(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() diff --git a/app/app_iface.go b/app/app_iface.go index c27ea210c1..f10ca2d8b9 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -24,6 +24,7 @@ import ( "github.com/mattermost/mattermost-server/v6/einterfaces" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin" + "github.com/mattermost/mattermost-server/v6/product" "github.com/mattermost/mattermost-server/v6/services/httpservice" "github.com/mattermost/mattermost-server/v6/services/imageproxy" "github.com/mattermost/mattermost-server/v6/services/remotecluster" @@ -651,6 +652,7 @@ type AppIface interface { GetGroupMemberCount(groupID string, viewRestrictions *model.ViewUsersRestrictions) (int64, *model.AppError) GetGroupMemberUsers(groupID string) ([]*model.User, *model.AppError) GetGroupMemberUsersPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, int, *model.AppError) + GetGroupMemberUsersSortedPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions, teammateNameDisplay string) ([]*model.User, int, *model.AppError) GetGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, *model.AppError) GetGroupSyncables(groupID string, syncableType model.GroupSyncableType) ([]*model.GroupSyncable, *model.AppError) GetGroups(page, perPage int, opts model.GroupSearchOpts, viewRestrictions *model.ViewUsersRestrictions) ([]*model.Group, *model.AppError) @@ -809,7 +811,7 @@ type AppIface interface { GetTopReactionsForUserSince(userID string, teamID string, opts *model.InsightsOpts) (*model.TopReactionList, *model.AppError) GetTopThreadsForTeamSince(c request.CTX, teamID, userID string, opts *model.InsightsOpts) (*model.TopThreadList, *model.AppError) GetTopThreadsForUserSince(c request.CTX, teamID, userID string, opts *model.InsightsOpts) (*model.TopThreadList, *model.AppError) - GetUploadSession(uploadId string) (*model.UploadSession, *model.AppError) + GetUploadSession(c request.CTX, uploadId string) (*model.UploadSession, *model.AppError) GetUploadSessionsForUser(userID string) ([]*model.UploadSession, *model.AppError) GetUser(userID string) (*model.User, *model.AppError) GetUserAccessToken(tokenID string, sanitize bool) (*model.UserAccessToken, *model.AppError) @@ -868,6 +870,7 @@ type AppIface interface { HasPermissionToTeam(askingUserId string, teamID string, permission *model.Permission) bool HasPermissionToUser(askingUserId string, userID string) bool HasSharedChannel(channelID string) (bool, error) + HooksManager() *product.HooksManager ImageProxy() *imageproxy.ImageProxy ImageProxyAdder() func(string) string ImageProxyRemover() (f func(string) string) @@ -916,6 +919,7 @@ type AppIface interface { Notification() einterfaces.NotificationInterface NotificationsLog() *mlog.Logger NotifyAndSetWarnMetricAck(warnMetricId string, sender *model.User, forceAck bool, isBot bool) *model.AppError + NotifySelfHostedSignupProgress(progress string, userId string) NotifySharedChannelUserUpdate(user *model.User) OpenInteractiveDialog(request model.OpenDialogRequest) *model.AppError OriginChecker() func(*http.Request) bool @@ -1147,7 +1151,7 @@ type AppIface interface { UpdateUserAuth(userID string, userAuth *model.UserAuth) (*model.UserAuth, *model.AppError) UpdateUserRoles(c request.CTX, userID string, newRoles string, sendWebSocketEvent bool) (*model.User, *model.AppError) UpdateUserRolesWithUser(c request.CTX, user *model.User, newRoles string, sendWebSocketEvent bool) (*model.User, *model.AppError) - UploadData(c *request.Context, us *model.UploadSession, rd io.Reader) (*model.FileInfo, *model.AppError) + UploadData(c request.CTX, us *model.UploadSession, rd io.Reader) (*model.FileInfo, *model.AppError) UploadEmojiImage(c request.CTX, id string, imageData *multipart.FileHeader) *model.AppError UpsertDraft(c *request.Context, draft *model.Draft, connectionID string) (*model.Draft, *model.AppError) UpsertGroupMember(groupID string, userID string) (*model.GroupMember, *model.AppError) @@ -1160,4 +1164,5 @@ type AppIface interface { VerifyUserEmail(userID, email string) *model.AppError ViewChannel(c request.CTX, view *model.ChannelView, userID string, currentSessionId string, collapsedThreadsSupported bool) (map[string]int64, *model.AppError) WriteFile(fr io.Reader, path string) (int64, *model.AppError) + WriteFileContext(ctx context.Context, fr io.Reader, path string) (int64, *model.AppError) } diff --git a/app/app_test.go b/app/app_test.go index 9f4d2fa0a9..05e12792b0 100644 --- a/app/app_test.go +++ b/app/app_test.go @@ -168,6 +168,7 @@ func TestDoAdvancedPermissionsMigration(t *testing.T) { model.PermissionCreateCustomGroup.Id, model.PermissionEditCustomGroup.Id, model.PermissionDeleteCustomGroup.Id, + model.PermissionRestoreCustomGroup.Id, model.PermissionManageCustomGroupMembers.Id, }, "system_post_all": { @@ -228,6 +229,7 @@ func TestDoEmojisPermissionsMigration(t *testing.T) { model.PermissionEditCustomGroup.Id, model.PermissionDeleteCustomGroup.Id, model.PermissionManageCustomGroupMembers.Id, + model.PermissionRestoreCustomGroup.Id, model.PermissionListPublicTeams.Id, model.PermissionJoinPublicTeams.Id, model.PermissionCreateDirectChannel.Id, diff --git a/app/channels.go b/app/channels.go index e4a25f3749..7ab024e45a 100644 --- a/app/channels.go +++ b/app/channels.go @@ -23,6 +23,8 @@ import ( "github.com/mattermost/mattermost-server/v6/shared/mlog" ) +const ServerKey product.ServiceKey = "server" + // licenseSvc is added to act as a starting point for future integrated products. // It has the same signature and functionality with the license related APIs of the plugin-api. type licenseSvc interface { @@ -86,19 +88,24 @@ type Channels struct { } func init() { - RegisterProduct("channels", ProductManifest{ - Initializer: func(s *Server, services map[ServiceKey]any) (Product, error) { - return NewChannels(s, services) + product.RegisterProduct("channels", product.Manifest{ + Initializer: func(services map[product.ServiceKey]any) (product.Product, error) { + return NewChannels(services) }, - Dependencies: map[ServiceKey]struct{}{ - ConfigKey: {}, - LicenseKey: {}, - FilestoreKey: {}, + Dependencies: map[product.ServiceKey]struct{}{ + ServerKey: {}, + product.ConfigKey: {}, + product.LicenseKey: {}, + product.FilestoreKey: {}, }, }) } -func NewChannels(s *Server, services map[ServiceKey]any) (*Channels, error) { +func NewChannels(services map[product.ServiceKey]any) (*Channels, error) { + s, ok := services[ServerKey].(*Server) + if !ok { + return nil, errors.New("server not passed") + } ch := &Channels{ srv: s, imageProxy: imageproxy.MakeImageProxy(s.platform, s.httpService, s.Log()), @@ -112,10 +119,10 @@ func NewChannels(s *Server, services map[ServiceKey]any) (*Channels, error) { // 2. Add the field to *Channels // 3. Add the service key to the slice. // 4. Add a new case in the switch statement. - requiredServices := []ServiceKey{ - ConfigKey, - LicenseKey, - FilestoreKey, + requiredServices := []product.ServiceKey{ + product.ConfigKey, + product.LicenseKey, + product.FilestoreKey, } for _, svcKey := range requiredServices { svc, ok := services[svcKey] @@ -124,19 +131,19 @@ func NewChannels(s *Server, services map[ServiceKey]any) (*Channels, error) { } switch svcKey { // Keep adding more services here - case ConfigKey: + case product.ConfigKey: cfgSvc, ok := svc.(product.ConfigService) if !ok { return nil, errors.New("Config service did not satisfy ConfigSvc interface") } ch.cfgSvc = cfgSvc - case FilestoreKey: + case product.FilestoreKey: filestore, ok := svc.(filestore.FileBackend) if !ok { return nil, errors.New("Filestore service did not satisfy FileBackend interface") } ch.filestore = filestore - case LicenseKey: + case product.LicenseKey: svc, ok := svc.(licenseSvc) if !ok { return nil, errors.New("License service did not satisfy licenseSvc interface") @@ -198,7 +205,7 @@ func NewChannels(s *Server, services map[ServiceKey]any) (*Channels, error) { } ch.routerSvc = newRouterService() - services[RouterKey] = ch.routerSvc + services[product.RouterKey] = ch.routerSvc // Setup routes. pluginsRoute := ch.srv.Router.PathPrefix("/plugins/{plugin_id:[A-Za-z0-9\\_\\-\\.]+}").Subrouter() @@ -206,29 +213,29 @@ func NewChannels(s *Server, services map[ServiceKey]any) (*Channels, error) { pluginsRoute.HandleFunc("/public/{public_file:.*}", ch.ServePluginPublicRequest) pluginsRoute.HandleFunc("/{anything:.*}", ch.ServePluginRequest) - services[PostKey] = &postServiceWrapper{ + services[product.PostKey] = &postServiceWrapper{ app: &App{ch: ch}, } - services[PermissionsKey] = &permissionsServiceWrapper{ + services[product.PermissionsKey] = &permissionsServiceWrapper{ app: &App{ch: ch}, } - services[TeamKey] = &teamServiceWrapper{ + services[product.TeamKey] = &teamServiceWrapper{ app: &App{ch: ch}, } - services[BotKey] = &botServiceWrapper{ + services[product.BotKey] = &botServiceWrapper{ app: &App{ch: ch}, } - services[HooksKey] = &hooksService{ + services[product.HooksKey] = &hooksService{ ch: ch, } - services[UserKey] = &App{ch: ch} + services[product.UserKey] = &App{ch: ch} - services[PreferencesKey] = &preferencesServiceWrapper{ + services[product.PreferencesKey] = &preferencesServiceWrapper{ app: &App{ch: ch}, } @@ -310,6 +317,10 @@ func (ch *Channels) RequestTrialLicense(requesterID string, users int, termsAcce receiveEmailsAccepted) } +func (a *App) HooksManager() *product.HooksManager { + return a.Srv().hooksManager +} + // Ensure hooksService implements `product.HooksService` var _ product.HooksService = (*hooksService)(nil) @@ -318,10 +329,6 @@ type hooksService struct { } func (s *hooksService) RegisterHooks(productID string, hooks any) error { - if s.ch.pluginsEnvironment == nil { - return errors.New("could not find plugins environment") - } - return s.ch.srv.hooksManager.AddProduct(productID, hooks) } diff --git a/app/export_test.go b/app/export_test.go index 066aec3771..7dbaa984b7 100644 --- a/app/export_test.go +++ b/app/export_test.go @@ -185,7 +185,7 @@ func TestExportAllUsers(t *testing.T) { defer th2.TearDown() err, i := th2.App.BulkImport(th2.Context, &b, nil, false, 5) assert.Nil(t, err) - assert.Equal(t, 0, i) + assert.EqualValues(t, 0, i) users1, err := th1.App.GetUsersFromProfiles(&model.UserGetOptions{ Page: 0, @@ -323,7 +323,7 @@ func TestExportDMChannelToSelf(t *testing.T) { // import the exported channel err, i := th2.App.BulkImport(th2.Context, &b, nil, false, 5) assert.Nil(t, err) - assert.Equal(t, 0, i) + assert.EqualValues(t, 0, i) channels, nErr = th2.App.Srv().Store().Channel().GetAllDirectChannelsForExportAfter(1000, "00000000") require.NoError(t, nErr) diff --git a/app/file.go b/app/file.go index 06a516bc86..7a66d25010 100644 --- a/app/file.go +++ b/app/file.go @@ -161,6 +161,10 @@ func (a *App) MoveFile(oldPath, newPath string) *model.AppError { return nil } +func (a *App) WriteFileContext(ctx context.Context, fr io.Reader, path string) (int64, *model.AppError) { + return a.Srv().writeFileContext(ctx, fr, path) +} + func (a *App) WriteFile(fr io.Reader, path string) (int64, *model.AppError) { return a.Srv().writeFile(fr, path) } @@ -173,6 +177,30 @@ func (s *Server) writeFile(fr io.Reader, path string) (int64, *model.AppError) { return result, nil } +func (s *Server) writeFileContext(ctx context.Context, fr io.Reader, path string) (int64, *model.AppError) { + type ContextWriter interface { + WriteFileContext(context.Context, io.Reader, string) (int64, error) + } + + var ( + fileBackend = s.FileBackend() + written int64 + err error + ) + + // Check if we can provide a custom context, otherwise just use the default method. + if cw, ok := fileBackend.(ContextWriter); ok { + written, err = cw.WriteFileContext(ctx, fr, path) + } else { + written, err = fileBackend.WriteFile(fr, path) + } + if err != nil { + return written, model.NewAppError("WriteFile", "api.file.write_file.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return written, nil +} + func (a *App) AppendFile(fr io.Reader, path string) (int64, *model.AppError) { result, nErr := a.FileBackend().AppendFile(fr, path) if nErr != nil { @@ -796,6 +824,7 @@ func (t *UploadFileTask) postprocessImage(file io.Reader) { _, aerr := t.writeFile(r, path) if aerr != nil { mlog.Error("Unable to upload", mlog.String("path", path), mlog.Err(aerr)) + r.CloseWithError(aerr) // always returns nil return } } diff --git a/app/group.go b/app/group.go index 6db059b33f..a01b369e41 100644 --- a/app/group.go +++ b/app/group.go @@ -250,8 +250,8 @@ func (a *App) GetGroupMemberUsers(groupID string) ([]*model.User, *model.AppErro return users, nil } -func (a *App) GetGroupMemberUsersPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, int, *model.AppError) { - members, err := a.Srv().Store().Group().GetMemberUsersPage(groupID, page, perPage, viewRestrictions) +func (a *App) GetGroupMemberUsersSortedPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions, teammateNameDisplay string) ([]*model.User, int, *model.AppError) { + members, err := a.Srv().Store().Group().GetMemberUsersSortedPage(groupID, page, perPage, viewRestrictions, teammateNameDisplay) if err != nil { return nil, 0, model.NewAppError("GetGroupMemberUsersPage", "app.select_error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -263,6 +263,10 @@ func (a *App) GetGroupMemberUsersPage(groupID string, page int, perPage int, vie return a.sanitizeProfiles(members, false), int(count), nil } +func (a *App) GetGroupMemberUsersPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, int, *model.AppError) { + return a.GetGroupMemberUsersSortedPage(groupID, page, perPage, viewRestrictions, model.ShowUsername) +} + func (a *App) GetUsersNotInGroupPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, *model.AppError) { members, err := a.Srv().Store().Group().GetNonMemberUsersPage(groupID, page, perPage, viewRestrictions) if err != nil { diff --git a/app/hosted_customer.go b/app/hosted_customer.go new file mode 100644 index 0000000000..d27945e85d --- /dev/null +++ b/app/hosted_customer.go @@ -0,0 +1,21 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "github.com/mattermost/mattermost-server/v6/model" +) + +func (a *App) NotifySelfHostedSignupProgress(progress string, userId string) { + // this is an event only the relevant admin should receive. + // If there is no progress, there is nothing to report. + // If there is no userId, we do not want to mistakenly broadcast to all users. + if progress == "" || userId == "" { + return + } + message := model.NewWebSocketEvent(model.WebsocketEventHostedCustomerSignupProgressUpdated, "", "", userId, nil, "") + message.Add("progress", progress) + + a.Srv().Platform().Publish(message) +} diff --git a/app/import.go b/app/import.go index 832524aa99..9510a91493 100644 --- a/app/import.go +++ b/app/import.go @@ -6,7 +6,6 @@ package app import ( "archive/zip" "bufio" - "bytes" "encoding/json" "fmt" "io" @@ -26,6 +25,7 @@ type ReactionImportData = imports.ReactionImportData // part of the app interfac const ( importMultiplePostsThreshold = 1000 maxScanTokenSize = 16 * 1024 * 1024 // Need to set a higher limit than default because some customers cross the limit. See MM-22314 + statusUpdateAfterLines = 8192 ) func stopOnError(c request.CTX, err imports.LineImportWorkerError) bool { @@ -41,7 +41,7 @@ func stopOnError(c request.CTX, err imports.LineImportWorkerError) bool { } } -func processAttachmentPaths(files *[]imports.AttachmentImportData, basePath string, filesMap map[string]*zip.File) error { +func processAttachmentPaths(c request.CTX, files *[]imports.AttachmentImportData, basePath string, filesMap map[string]*zip.File) error { if files == nil { return nil } @@ -61,20 +61,20 @@ func processAttachmentPaths(files *[]imports.AttachmentImportData, basePath stri return nil } -func processAttachments(line *imports.LineImportData, basePath string, filesMap map[string]*zip.File) error { +func processAttachments(c request.CTX, line *imports.LineImportData, basePath string, filesMap map[string]*zip.File) error { var ok bool switch line.Type { case "post", "direct_post": var replies []imports.ReplyImportData if line.Type == "direct_post" { - if err := processAttachmentPaths(line.DirectPost.Attachments, basePath, filesMap); err != nil { + if err := processAttachmentPaths(c, line.DirectPost.Attachments, basePath, filesMap); err != nil { return err } if line.DirectPost.Replies != nil { replies = *line.DirectPost.Replies } } else { - if err := processAttachmentPaths(line.Post.Attachments, basePath, filesMap); err != nil { + if err := processAttachmentPaths(c, line.Post.Attachments, basePath, filesMap); err != nil { return err } if line.Post.Replies != nil { @@ -82,7 +82,7 @@ func processAttachments(line *imports.LineImportData, basePath string, filesMap } } for _, reply := range replies { - if err := processAttachmentPaths(reply.Attachments, basePath, filesMap); err != nil { + if err := processAttachmentPaths(c, reply.Attachments, basePath, filesMap); err != nil { return err } } @@ -112,6 +112,15 @@ func processAttachments(line *imports.LineImportData, basePath string, filesMap } func (a *App) bulkImportWorker(c request.CTX, dryRun bool, wg *sync.WaitGroup, lines <-chan imports.LineImportWorkerData, errors chan<- imports.LineImportWorkerError) { + workerID := model.NewId() + processedLines := uint64(0) + + c.Logger().Info("Started new bulk import worker", mlog.String("bulk_import_worker_id", workerID)) + defer func() { + wg.Done() + c.Logger().Info("Bulk import worker finished", mlog.String("bulk_import_worker_id", workerID), mlog.Uint64("processed_lines", processedLines)) + }() + postLines := []imports.LineImportWorkerData{} directPostLines := []imports.LineImportWorkerData{} for line := range lines { @@ -143,6 +152,11 @@ func (a *App) bulkImportWorker(c request.CTX, dryRun bool, wg *sync.WaitGroup, l errors <- imports.LineImportWorkerError{Error: err, LineNumber: line.LineNumber} } } + + processedLines++ + if processedLines%statusUpdateAfterLines == 0 { + c.Logger().Info("Worker progress", mlog.String("bulk_import_worker_id", workerID), mlog.Uint64("processed_lines", processedLines)) + } } if len(postLines) > 0 { @@ -155,7 +169,6 @@ func (a *App) bulkImportWorker(c request.CTX, dryRun bool, wg *sync.WaitGroup, l errors <- imports.LineImportWorkerError{Error: err, LineNumber: errLine} } } - wg.Done() } func (a *App) BulkImport(c *request.Context, jsonlReader io.Reader, attachmentsReader *zip.Reader, dryRun bool, workers int) (*model.AppError, int) { @@ -194,15 +207,17 @@ func (a *App) bulkImport(c request.CTX, jsonlReader io.Reader, attachmentsReader } for scanner.Scan() { - decoder := json.NewDecoder(bytes.NewReader(scanner.Bytes())) lineNumber++ + if lineNumber%statusUpdateAfterLines == 0 { + c.Logger().Info("Reader progress", mlog.Int("processed_lines", lineNumber)) + } var line imports.LineImportData - if err := decoder.Decode(&line); err != nil { + if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { return model.NewAppError("BulkImport", "app.import.bulk_import.json_decode.error", nil, "", http.StatusBadRequest).Wrap(err), lineNumber } - if err := processAttachments(&line, importPath, attachedFiles); err != nil { + if err := processAttachments(c, &line, importPath, attachedFiles); err != nil { c.Logger().Warn("Error while processing import attachments. Objects might be broken.", mlog.Err(err)) } @@ -222,6 +237,12 @@ func (a *App) bulkImport(c request.CTX, jsonlReader io.Reader, attachmentsReader if line.Type != lastLineType { // Only clear the worker queue if is not the first data entry if lineNumber != 2 { + c.Logger().Info( + "Finished parsing segment, waiting for workers to finish", + mlog.String("old_segment", lastLineType), + mlog.String("new_segment", line.Type), + ) + // Changing type. Clear out the worker queue before continuing. close(linesChan) wg.Wait() @@ -235,6 +256,13 @@ func (a *App) bulkImport(c request.CTX, jsonlReader io.Reader, attachmentsReader } } + c.Logger().Info( + "Starting workers for new segment", + mlog.String("old_segment", lastLineType), + mlog.String("new_segment", line.Type), + mlog.Int("workers", workers), + ) + // Set up the workers and channel for this type. lastLineType = line.Type linesChan = make(chan imports.LineImportWorkerData, workers) @@ -290,7 +318,7 @@ func (a *App) importLine(c request.CTX, line imports.LineImportData, dryRun bool if line.Scheme == nil { return model.NewAppError("BulkImport", "app.import.import_line.null_scheme.error", nil, "", http.StatusBadRequest) } - return a.importScheme(line.Scheme, dryRun) + return a.importScheme(c, line.Scheme, dryRun) case line.Type == "team": if line.Team == nil { return model.NewAppError("BulkImport", "app.import.import_line.null_team.error", nil, "", http.StatusBadRequest) @@ -315,7 +343,7 @@ func (a *App) importLine(c request.CTX, line imports.LineImportData, dryRun bool if line.Emoji == nil { return model.NewAppError("BulkImport", "app.import.import_line.null_emoji.error", nil, "", http.StatusBadRequest) } - return a.importEmoji(line.Emoji, dryRun) + return a.importEmoji(c, line.Emoji, dryRun) default: return model.NewAppError("BulkImport", "app.import.import_line.unknown_line_type.error", map[string]any{"Type": line.Type}, "", http.StatusBadRequest) } diff --git a/app/import_functions.go b/app/import_functions.go index bf6d073f72..1f28b046d3 100644 --- a/app/import_functions.go +++ b/app/import_functions.go @@ -15,6 +15,7 @@ import ( "path" "strings" + "github.com/mattermost/logr/v2" "github.com/mattermost/mattermost-server/v6/app/imports" "github.com/mattermost/mattermost-server/v6/app/request" "github.com/mattermost/mattermost-server/v6/app/teams" @@ -25,13 +26,16 @@ import ( "github.com/mattermost/mattermost-server/v6/utils" ) -// // -- Bulk Import Functions -- // These functions import data directly into the database. Security and permission checks are bypassed but validity is // still enforced. -// +func (a *App) importScheme(c request.CTX, data *imports.SchemeImportData, dryRun bool) *model.AppError { + var fields []logr.Field + if data != nil && data.Name != nil { + fields = append(fields, mlog.String("schema_name", *data.Name)) + } + c.Logger().Info("Validating schema", fields...) -func (a *App) importScheme(data *imports.SchemeImportData, dryRun bool) *model.AppError { if err := imports.ValidateSchemeImportData(data); err != nil { return err } @@ -41,6 +45,8 @@ func (a *App) importScheme(data *imports.SchemeImportData, dryRun bool) *model.A return nil } + c.Logger().Info("Importing schema", fields...) + scheme, err := a.GetSchemeByName(*data.Name) if err != nil { scheme = new(model.Scheme) @@ -68,12 +74,12 @@ func (a *App) importScheme(data *imports.SchemeImportData, dryRun bool) *model.A if scheme.Scope == model.SchemeScopeTeam { data.DefaultTeamAdminRole.Name = &scheme.DefaultTeamAdminRole - if err := a.importRole(data.DefaultTeamAdminRole, dryRun, true); err != nil { + if err := a.importRole(c, data.DefaultTeamAdminRole, dryRun, true); err != nil { return err } data.DefaultTeamUserRole.Name = &scheme.DefaultTeamUserRole - if err := a.importRole(data.DefaultTeamUserRole, dryRun, true); err != nil { + if err := a.importRole(c, data.DefaultTeamUserRole, dryRun, true); err != nil { return err } @@ -83,19 +89,19 @@ func (a *App) importScheme(data *imports.SchemeImportData, dryRun bool) *model.A } } data.DefaultTeamGuestRole.Name = &scheme.DefaultTeamGuestRole - if err := a.importRole(data.DefaultTeamGuestRole, dryRun, true); err != nil { + if err := a.importRole(c, data.DefaultTeamGuestRole, dryRun, true); err != nil { return err } } if scheme.Scope == model.SchemeScopeTeam || scheme.Scope == model.SchemeScopeChannel { data.DefaultChannelAdminRole.Name = &scheme.DefaultChannelAdminRole - if err := a.importRole(data.DefaultChannelAdminRole, dryRun, true); err != nil { + if err := a.importRole(c, data.DefaultChannelAdminRole, dryRun, true); err != nil { return err } data.DefaultChannelUserRole.Name = &scheme.DefaultChannelUserRole - if err := a.importRole(data.DefaultChannelUserRole, dryRun, true); err != nil { + if err := a.importRole(c, data.DefaultChannelUserRole, dryRun, true); err != nil { return err } @@ -105,7 +111,7 @@ func (a *App) importScheme(data *imports.SchemeImportData, dryRun bool) *model.A } } data.DefaultChannelGuestRole.Name = &scheme.DefaultChannelGuestRole - if err := a.importRole(data.DefaultChannelGuestRole, dryRun, true); err != nil { + if err := a.importRole(c, data.DefaultChannelGuestRole, dryRun, true); err != nil { return err } } @@ -113,8 +119,15 @@ func (a *App) importScheme(data *imports.SchemeImportData, dryRun bool) *model.A return nil } -func (a *App) importRole(data *imports.RoleImportData, dryRun bool, isSchemeRole bool) *model.AppError { +func (a *App) importRole(c request.CTX, data *imports.RoleImportData, dryRun bool, isSchemeRole bool) *model.AppError { + var fields []logr.Field + if data != nil && data.Name != nil { + fields = append(fields, mlog.String("role_name", *data.Name)) + } + if !isSchemeRole { + c.Logger().Info("Validating role", fields...) + if err := imports.ValidateRoleImportData(data); err != nil { return err } @@ -125,6 +138,8 @@ func (a *App) importRole(data *imports.RoleImportData, dryRun bool, isSchemeRole return nil } + c.Logger().Info("Importing role", fields...) + role, err := a.GetRoleByName(context.Background(), *data.Name) if err != nil { role = new(model.Role) @@ -160,6 +175,12 @@ func (a *App) importRole(data *imports.RoleImportData, dryRun bool, isSchemeRole } func (a *App) importTeam(c request.CTX, data *imports.TeamImportData, dryRun bool) *model.AppError { + var fields []logr.Field + if data != nil && data.Name != nil { + fields = append(fields, mlog.String("team_name", *data.Name)) + } + c.Logger().Info("Validating team", fields...) + if err := imports.ValidateTeamImportData(data); err != nil { return err } @@ -169,6 +190,8 @@ func (a *App) importTeam(c request.CTX, data *imports.TeamImportData, dryRun boo return nil } + c.Logger().Info("Importing team", fields...) + var team *model.Team team, err := a.Srv().Store().Team().GetByName(*data.Name) @@ -228,6 +251,12 @@ func (a *App) importTeam(c request.CTX, data *imports.TeamImportData, dryRun boo } func (a *App) importChannel(c request.CTX, data *imports.ChannelImportData, dryRun bool) *model.AppError { + var fields []logr.Field + if data != nil && data.Name != nil { + fields = append(fields, mlog.String("channel_name", *data.Name)) + } + c.Logger().Info("Validating channel", fields...) + if err := imports.ValidateChannelImportData(data); err != nil { return err } @@ -237,6 +266,8 @@ func (a *App) importChannel(c request.CTX, data *imports.ChannelImportData, dryR return nil } + c.Logger().Info("Importing channel", fields...) + team, err := a.Srv().Store().Team().GetByName(*data.Team) if err != nil { return model.NewAppError("BulkImport", "app.import.import_channel.team_not_found.error", map[string]any{"TeamName": *data.Team}, "", http.StatusBadRequest).Wrap(err) @@ -293,6 +324,12 @@ func (a *App) importChannel(c request.CTX, data *imports.ChannelImportData, dryR } func (a *App) importUser(c request.CTX, data *imports.UserImportData, dryRun bool) *model.AppError { + var fields []logr.Field + if data != nil && data.Username != nil { + fields = append(fields, mlog.String("user_name", *data.Username)) + } + c.Logger().Info("Validating user", fields...) + if err := imports.ValidateUserImportData(data); err != nil { return err } @@ -302,6 +339,8 @@ func (a *App) importUser(c request.CTX, data *imports.UserImportData, dryRun boo return nil } + c.Logger().Info("Importing user", fields...) + // We want to avoid database writes if nothing has changed. hasUserChanged := false hasNotifyPropsChanged := false @@ -1214,6 +1253,8 @@ func (a *App) importAttachment(c request.CTX, data *imports.AttachmentImportData defer zipFile.Close() name = data.Data.Name file = zipFile.(io.Reader) + + c.Logger().Info("Preparing file upload from ZIP", mlog.String("file_name", name), mlog.Uint64("file_size", data.Data.UncompressedSize64)) } else { realFile, err := os.Open(*data.Path) if err != nil { @@ -1222,6 +1263,12 @@ func (a *App) importAttachment(c request.CTX, data *imports.AttachmentImportData defer realFile.Close() name = realFile.Name() file = realFile + + fields := []logr.Field{mlog.String("file_name", name)} + if info, err := realFile.Stat(); err != nil { + fields = append(fields, mlog.Int64("file_size", info.Size())) + } + c.Logger().Info("Preparing file upload from file system", fields...) } timestamp := utils.TimeFromMillis(post.CreateAt) @@ -1241,7 +1288,8 @@ func (a *App) importAttachment(c request.CTX, data *imports.AttachmentImportData if oldFile.Name != path.Base(name) || oldFile.Size != int64(len(fileData)) { continue } - // check md5 + + // check sha1 newHash := sha1.Sum(fileData) oldFileData, err := a.getFileIgnoreCloudLimit(oldFile.Id) if err != nil { @@ -1260,7 +1308,7 @@ func (a *App) importAttachment(c request.CTX, data *imports.AttachmentImportData fileInfo, appErr := a.DoUploadFile(c, timestamp, teamID, post.ChannelId, post.UserId, name, fileData) if appErr != nil { - mlog.Error("Failed to upload file:", mlog.Err(appErr)) + mlog.Error("Failed to upload file", mlog.Err(appErr), mlog.String("file_name", name)) return nil, appErr } @@ -1358,6 +1406,8 @@ func (a *App) importMultiplePostLines(c request.CTX, lines []imports.LineImportW return 0, nil } + c.Logger().Info("Validating post lines", mlog.Int("count", len(lines)), mlog.Int("first_line", lines[0].LineNumber)) + for _, line := range lines { if err := imports.ValidatePostImportData(line.Post, a.MaxPostSize()); err != nil { return line.LineNumber, err @@ -1369,6 +1419,8 @@ func (a *App) importMultiplePostLines(c request.CTX, lines []imports.LineImportW return 0, nil } + c.Logger().Info("Importing post lines", mlog.Int("count", len(lines)), mlog.Int("first_line", lines[0].LineNumber)) + usernames := []string{} teamNames := make([]string, len(lines)) postsData := make([]*imports.PostImportData, len(lines)) @@ -1855,7 +1907,13 @@ func (a *App) importMultipleDirectPostLines(c request.CTX, lines []imports.LineI return 0, nil } -func (a *App) importEmoji(data *imports.EmojiImportData, dryRun bool) *model.AppError { +func (a *App) importEmoji(c request.CTX, data *imports.EmojiImportData, dryRun bool) *model.AppError { + var fields []logr.Field + if data != nil && data.Name != nil { + fields = append(fields, mlog.String("emoji_name", *data.Name)) + } + c.Logger().Info("Validating emoji", fields...) + aerr := imports.ValidateEmojiImportData(data) if aerr != nil { if aerr.Id == "model.emoji.system_emoji_name.app_error" { @@ -1870,6 +1928,8 @@ func (a *App) importEmoji(data *imports.EmojiImportData, dryRun bool) *model.App return nil } + c.Logger().Info("Importing emoji", fields...) + var emoji *model.Emoji emoji, err := a.Srv().Store().Emoji().GetByName(context.Background(), *data.Name, true) diff --git a/app/import_functions_test.go b/app/import_functions_test.go index bb2271eeb6..864eef4a7f 100644 --- a/app/import_functions_test.go +++ b/app/import_functions_test.go @@ -65,7 +65,7 @@ func TestImportImportScheme(t *testing.T) { Description: ptrStr("description"), } - err := th.App.importScheme(&data, true) + err := th.App.importScheme(th.Context, &data, true) require.NotNil(t, err, "Should have failed to import.") _, nErr := th.App.Srv().Store().Scheme().GetByName(*data.Name) @@ -74,7 +74,7 @@ func TestImportImportScheme(t *testing.T) { // Try importing a valid scheme in dryRun mode. data.DisplayName = ptrStr("display name") - err = th.App.importScheme(&data, true) + err = th.App.importScheme(th.Context, &data, true) require.Nil(t, err, "Should have succeeded.") _, nErr = th.App.Srv().Store().Scheme().GetByName(*data.Name) @@ -83,7 +83,7 @@ func TestImportImportScheme(t *testing.T) { // Try importing an invalid scheme. data.DisplayName = nil - err = th.App.importScheme(&data, false) + err = th.App.importScheme(th.Context, &data, false) require.NotNil(t, err, "Should have failed to import.") _, nErr = th.App.Srv().Store().Scheme().GetByName(*data.Name) @@ -92,7 +92,7 @@ func TestImportImportScheme(t *testing.T) { // Try importing a valid scheme with all params set. data.DisplayName = ptrStr("display name") - err = th.App.importScheme(&data, false) + err = th.App.importScheme(th.Context, &data, false) require.Nil(t, err, "Should have succeeded.") scheme, nErr := th.App.Srv().Store().Scheme().GetByName(*data.Name) @@ -149,7 +149,7 @@ func TestImportImportScheme(t *testing.T) { data.DisplayName = ptrStr("new display name") data.Description = ptrStr("new description") - err = th.App.importScheme(&data, false) + err = th.App.importScheme(th.Context, &data, false) require.Nil(t, err, "Should have succeeded: %v", err) scheme, nErr = th.App.Srv().Store().Scheme().GetByName(*data.Name) @@ -205,7 +205,7 @@ func TestImportImportScheme(t *testing.T) { // Try changing the scope of the scheme and reimporting. data.Scope = ptrStr("channel") - err = th.App.importScheme(&data, false) + err = th.App.importScheme(th.Context, &data, false) require.NotNil(t, err, "Should have failed to import.") scheme, nErr = th.App.Srv().Store().Scheme().GetByName(*data.Name) @@ -252,7 +252,7 @@ func TestImportImportSchemeWithoutGuestRoles(t *testing.T) { Description: ptrStr("description"), } - err := th.App.importScheme(&data, true) + err := th.App.importScheme(th.Context, &data, true) require.NotNil(t, err, "Should have failed to import.") _, nErr := th.App.Srv().Store().Scheme().GetByName(*data.Name) @@ -261,7 +261,7 @@ func TestImportImportSchemeWithoutGuestRoles(t *testing.T) { // Try importing a valid scheme in dryRun mode. data.DisplayName = ptrStr("display name") - err = th.App.importScheme(&data, true) + err = th.App.importScheme(th.Context, &data, true) require.Nil(t, err, "Should have succeeded.") _, nErr = th.App.Srv().Store().Scheme().GetByName(*data.Name) @@ -270,7 +270,7 @@ func TestImportImportSchemeWithoutGuestRoles(t *testing.T) { // Try importing an invalid scheme. data.DisplayName = nil - err = th.App.importScheme(&data, false) + err = th.App.importScheme(th.Context, &data, false) require.NotNil(t, err, "Should have failed to import.") _, nErr = th.App.Srv().Store().Scheme().GetByName(*data.Name) @@ -279,7 +279,7 @@ func TestImportImportSchemeWithoutGuestRoles(t *testing.T) { // Try importing a valid scheme with all params set. data.DisplayName = ptrStr("display name") - err = th.App.importScheme(&data, false) + err = th.App.importScheme(th.Context, &data, false) require.Nil(t, err, "Should have succeeded.") scheme, nErr := th.App.Srv().Store().Scheme().GetByName(*data.Name) @@ -336,7 +336,7 @@ func TestImportImportSchemeWithoutGuestRoles(t *testing.T) { data.DisplayName = ptrStr("new display name") data.Description = ptrStr("new description") - err = th.App.importScheme(&data, false) + err = th.App.importScheme(th.Context, &data, false) require.Nil(t, err, "Should have succeeded: %v", err) scheme, nErr = th.App.Srv().Store().Scheme().GetByName(*data.Name) @@ -392,7 +392,7 @@ func TestImportImportSchemeWithoutGuestRoles(t *testing.T) { // Try changing the scope of the scheme and reimporting. data.Scope = ptrStr("channel") - err = th.App.importScheme(&data, false) + err = th.App.importScheme(th.Context, &data, false) require.NotNil(t, err, "Should have failed to import.") scheme, nErr = th.App.Srv().Store().Scheme().GetByName(*data.Name) @@ -414,7 +414,7 @@ func TestImportImportRole(t *testing.T) { Name: &rid1, } - err := th.App.importRole(&data, true, false) + err := th.App.importRole(th.Context, &data, true, false) require.NotNil(t, err, "Should have failed to import.") _, nErr := th.App.Srv().Store().Role().GetByName(context.Background(), rid1) @@ -423,7 +423,7 @@ func TestImportImportRole(t *testing.T) { // Try importing the valid role in dryRun mode. data.DisplayName = ptrStr("display name") - err = th.App.importRole(&data, true, false) + err = th.App.importRole(th.Context, &data, true, false) require.Nil(t, err, "Should have succeeded.") _, nErr = th.App.Srv().Store().Role().GetByName(context.Background(), rid1) @@ -432,7 +432,7 @@ func TestImportImportRole(t *testing.T) { // Try importing an invalid role. data.DisplayName = nil - err = th.App.importRole(&data, false, false) + err = th.App.importRole(th.Context, &data, false, false) require.NotNil(t, err, "Should have failed to import.") _, nErr = th.App.Srv().Store().Role().GetByName(context.Background(), rid1) @@ -443,7 +443,7 @@ func TestImportImportRole(t *testing.T) { data.Description = ptrStr("description") data.Permissions = &[]string{"invite_user", "add_user_to_team"} - err = th.App.importRole(&data, false, false) + err = th.App.importRole(th.Context, &data, false, false) require.Nil(t, err, "Should have succeeded.") role, nErr := th.App.Srv().Store().Role().GetByName(context.Background(), rid1) @@ -461,7 +461,7 @@ func TestImportImportRole(t *testing.T) { data.Description = ptrStr("description") data.Permissions = &[]string{"use_slash_commands"} - err = th.App.importRole(&data, false, true) + err = th.App.importRole(th.Context, &data, false, true) require.Nil(t, err, "Should have succeeded. %v", err) role, nErr = th.App.Srv().Store().Role().GetByName(context.Background(), rid1) @@ -480,7 +480,7 @@ func TestImportImportRole(t *testing.T) { DisplayName: ptrStr("new display name again"), } - err = th.App.importRole(&data2, false, false) + err = th.App.importRole(th.Context, &data2, false, false) require.Nil(t, err, "Should have succeeded.") role, nErr = th.App.Srv().Store().Role().GetByName(context.Background(), rid1) @@ -1384,7 +1384,7 @@ func TestImportImportUser(t *testing.T) { Description: ptrStr("description"), } - appErr = th.App.importScheme(teamSchemeData, false) + appErr = th.App.importScheme(th.Context, teamSchemeData, false) assert.Nil(t, appErr) teamScheme, nErr := th.App.Srv().Store().Scheme().GetByName(*teamSchemeData.Name) @@ -4151,7 +4151,7 @@ func TestImportImportEmoji(t *testing.T) { testImage := filepath.Join(testsDir, "test.png") data := imports.EmojiImportData{Name: ptrStr(model.NewId())} - appErr := th.App.importEmoji(&data, true) + appErr := th.App.importEmoji(th.Context, &data, true) assert.NotNil(t, appErr, "Invalid emoji should have failed dry run") emoji, nErr := th.App.Srv().Store().Emoji().GetByName(context.Background(), *data.Name, true) @@ -4159,35 +4159,35 @@ func TestImportImportEmoji(t *testing.T) { assert.Error(t, nErr) data.Image = ptrStr(testImage) - appErr = th.App.importEmoji(&data, true) + appErr = th.App.importEmoji(th.Context, &data, true) assert.Nil(t, appErr, "Valid emoji should have passed dry run") data = imports.EmojiImportData{Name: ptrStr(model.NewId())} - appErr = th.App.importEmoji(&data, false) + appErr = th.App.importEmoji(th.Context, &data, false) assert.NotNil(t, appErr, "Invalid emoji should have failed apply mode") data.Image = ptrStr("non-existent-file") - appErr = th.App.importEmoji(&data, false) + appErr = th.App.importEmoji(th.Context, &data, false) assert.NotNil(t, appErr, "Emoji with bad image file should have failed apply mode") data.Image = ptrStr(testImage) - appErr = th.App.importEmoji(&data, false) + appErr = th.App.importEmoji(th.Context, &data, false) assert.Nil(t, appErr, "Valid emoji should have succeeded apply mode") emoji, nErr = th.App.Srv().Store().Emoji().GetByName(context.Background(), *data.Name, true) assert.NotNil(t, emoji, "Emoji should have been imported") assert.NoError(t, nErr, "Emoji should have been imported without any error") - appErr = th.App.importEmoji(&data, false) + appErr = th.App.importEmoji(th.Context, &data, false) assert.Nil(t, appErr, "Second run should have succeeded apply mode") data = imports.EmojiImportData{Name: ptrStr("smiley"), Image: ptrStr(testImage)} - appErr = th.App.importEmoji(&data, false) + appErr = th.App.importEmoji(th.Context, &data, false) assert.Nil(t, appErr, "System emoji should not fail") largeImage := filepath.Join(testsDir, "large_image_file.jpg") data = imports.EmojiImportData{Name: ptrStr(model.NewId()), Image: ptrStr(largeImage)} - appErr = th.App.importEmoji(&data, false) + appErr = th.App.importEmoji(th.Context, &data, false) require.NotNil(t, appErr) require.ErrorIs(t, appErr.Unwrap(), utils.SizeLimitExceeded) } diff --git a/app/import_test.go b/app/import_test.go index 0ddb6834ec..60f8778165 100644 --- a/app/import_test.go +++ b/app/import_test.go @@ -17,7 +17,9 @@ import ( "github.com/stretchr/testify/require" "github.com/mattermost/mattermost-server/v6/app/imports" + "github.com/mattermost/mattermost-server/v6/app/request" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/mlog" "github.com/mattermost/mattermost-server/v6/utils" "github.com/mattermost/mattermost-server/v6/utils/fileutils" ) @@ -238,7 +240,7 @@ func TestImportBulkImport(t *testing.T) { {"type": "user", "user": {"username": "` + username + `", "email": "` + username + `@example.com", "teams": [{"name": "` + teamName + `","theme": "` + teamTheme1 + `", "channels": [{"name": "` + channelName + `"}]}]}} {"type": "post", "post": {"team": "` + teamName + `", "channel": "` + channelName + `", "user": "` + username + `", "message": "Hello World", "create_at": 123456789012, "attachments":[{"path": "` + testImage + `"}], "props":{"attachments":[{"id":0,"fallback":"[February 4th, 2020 2:46 PM] author: fallback","color":"D0D0D0","pretext":"","author_name":"author","author_link":"","title":"","title_link":"","text":"this post has props","fields":null,"image_url":"","thumb_url":"","footer":"Posted in #general","footer_icon":"","ts":"1580823992.000100"}]}}} {"type": "direct_channel", "direct_channel": {"members": ["` + username + `", "` + username + `"]}} -{"type": "direct_post", "direct_post": {"channel_members": ["` + username + `", "` + username + `"], "user": "` + username + `", "message": "Hello Direct Channel to myself", "create_at": 123456789014, "props":{"attachments":[{"id":0,"fallback":"[February 4th, 2020 2:46 PM] author: fallback","color":"D0D0D0","pretext":"","author_name":"author","author_link":"","title":"","title_link":"","text":"this post has props","fields":null,"image_url":"","thumb_url":"","footer":"Posted in #general","footer_icon":"","ts":"1580823992.000100"}]}}}}` +{"type": "direct_post", "direct_post": {"channel_members": ["` + username + `", "` + username + `"], "user": "` + username + `", "message": "Hello Direct Channel to myself", "create_at": 123456789014, "props":{"attachments":[{"id":0,"fallback":"[February 4th, 2020 2:46 PM] author: fallback","color":"D0D0D0","pretext":"","author_name":"author","author_link":"","title":"","title_link":"","text":"this post has props","fields":null,"image_url":"","thumb_url":"","footer":"Posted in #general","footer_icon":"","ts":"1580823992.000100"}]}}}` err, line := th.App.BulkImport(th.Context, strings.NewReader(data6), nil, false, 2) require.Nil(t, err, "BulkImport should have succeeded") @@ -285,6 +287,9 @@ func AssertFileIdsInPost(files []*model.FileInfo, th *TestHelper, t *testing.T) } func TestProcessAttachments(t *testing.T) { + logger, _ := mlog.NewLogger() + c := request.EmptyContext(logger) + genAttachments := func() *[]imports.AttachmentImportData { return &[]imports.AttachmentImportData{ { @@ -333,10 +338,11 @@ func TestProcessAttachments(t *testing.T) { Path: model.NewString("somedir/file.jpg"), }, } - err := processAttachments(&line, "", nil) + + err := processAttachments(c, &line, "", nil) require.NoError(t, err) require.Equal(t, expected, line.Post.Attachments) - err = processAttachments(&line2, "", nil) + err = processAttachments(c, &line2, "", nil) require.NoError(t, err) require.Equal(t, expected, line2.DirectPost.Attachments) }) @@ -352,27 +358,27 @@ func TestProcessAttachments(t *testing.T) { } t.Run("post attachments", func(t *testing.T) { - err := processAttachments(&line, "/tmp", nil) + err := processAttachments(c, &line, "/tmp", nil) require.NoError(t, err) require.Equal(t, expected, line.Post.Attachments) }) t.Run("direct post attachments", func(t *testing.T) { - err := processAttachments(&line2, "/tmp", nil) + err := processAttachments(c, &line2, "/tmp", nil) require.NoError(t, err) require.Equal(t, expected, line2.DirectPost.Attachments) }) t.Run("profile image", func(t *testing.T) { expected := "/tmp/profile.jpg" - err := processAttachments(&userLine, "/tmp", nil) + err := processAttachments(c, &userLine, "/tmp", nil) require.NoError(t, err) require.Equal(t, expected, *userLine.User.ProfileImage) }) t.Run("emoji", func(t *testing.T) { expected := "/tmp/emoji.png" - err := processAttachments(&emojiLine, "/tmp", nil) + err := processAttachments(c, &emojiLine, "/tmp", nil) require.NoError(t, err) require.Equal(t, expected, *emojiLine.Emoji.Image) }) @@ -383,11 +389,11 @@ func TestProcessAttachments(t *testing.T) { filesMap := map[string]*zip.File{ "/tmp/file.jpg": nil, } - err := processAttachments(&line, "", filesMap) + err := processAttachments(c, &line, "", filesMap) require.Error(t, err) filesMap["/tmp/somedir/file.jpg"] = nil - err = processAttachments(&line, "", filesMap) + err = processAttachments(c, &line, "", filesMap) require.NoError(t, err) }) @@ -395,11 +401,11 @@ func TestProcessAttachments(t *testing.T) { filesMap := map[string]*zip.File{ "/tmp/file.jpg": nil, } - err := processAttachments(&line2, "", filesMap) + err := processAttachments(c, &line2, "", filesMap) require.Error(t, err) filesMap["/tmp/somedir/file.jpg"] = nil - err = processAttachments(&line2, "", filesMap) + err = processAttachments(c, &line2, "", filesMap) require.NoError(t, err) }) @@ -407,11 +413,11 @@ func TestProcessAttachments(t *testing.T) { filesMap := map[string]*zip.File{ "/tmp/file.jpg": nil, } - err := processAttachments(&userLine, "", filesMap) + err := processAttachments(c, &userLine, "", filesMap) require.Error(t, err) filesMap["/tmp/profile.jpg"] = nil - err = processAttachments(&userLine, "", filesMap) + err = processAttachments(c, &userLine, "", filesMap) require.NoError(t, err) }) @@ -419,11 +425,11 @@ func TestProcessAttachments(t *testing.T) { filesMap := map[string]*zip.File{ "/tmp/file.jpg": nil, } - err := processAttachments(&emojiLine, "", filesMap) + err := processAttachments(c, &emojiLine, "", filesMap) require.Error(t, err) filesMap["/tmp/emoji.png"] = nil - err = processAttachments(&emojiLine, "", filesMap) + err = processAttachments(c, &emojiLine, "", filesMap) require.NoError(t, err) }) }) diff --git a/app/license.go b/app/license.go index 9afff64349..61b4d2dc85 100644 --- a/app/license.go +++ b/app/license.go @@ -32,8 +32,8 @@ type licenseWrapper struct { srv *Server } -func (w *licenseWrapper) Name() ServiceKey { - return LicenseKey +func (w *licenseWrapper) Name() product.ServiceKey { + return product.LicenseKey } func (w *licenseWrapper) GetLicense() *model.License { diff --git a/app/notification_push_test.go b/app/notification_push_test.go index 73bf38e72b..7c8a6d78c1 100644 --- a/app/notification_push_test.go +++ b/app/notification_push_test.go @@ -20,6 +20,7 @@ import ( "github.com/mattermost/mattermost-server/v6/app/platform" "github.com/mattermost/mattermost-server/v6/config" "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/product" fmocks "github.com/mattermost/mattermost-server/v6/shared/filestore/mocks" "github.com/mattermost/mattermost-server/v6/shared/i18n" "github.com/mattermost/mattermost-server/v6/store/storetest/mocks" @@ -1440,7 +1441,7 @@ func TestPushNotificationRace(t *testing.T) { Return(&model.Preference{Value: "test"}, nil) mockStore.On("Preference").Return(&mockPreferenceStore) s := &Server{ - products: make(map[string]Product), + products: make(map[string]product.Product), Router: mux.NewRouter(), } var err error @@ -1449,12 +1450,13 @@ func TestPushNotificationRace(t *testing.T) { }, platform.SetFileStore(&fmocks.FileBackend{})) s.SetStore(mockStore) require.NoError(t, err) - serviceMap := map[ServiceKey]any{ - ConfigKey: s.platform, - LicenseKey: &licenseWrapper{s}, - FilestoreKey: s.FileBackend(), + serviceMap := map[product.ServiceKey]any{ + ServerKey: s, + product.ConfigKey: s.platform, + product.LicenseKey: &licenseWrapper{s}, + product.FilestoreKey: s.FileBackend(), } - ch, err := NewChannels(s, serviceMap) + ch, err := NewChannels(serviceMap) require.NoError(t, err) s.products["channels"] = ch diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 2d60825ebc..367d98ec39 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -25,6 +25,7 @@ import ( "github.com/mattermost/mattermost-server/v6/einterfaces" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin" + "github.com/mattermost/mattermost-server/v6/product" "github.com/mattermost/mattermost-server/v6/services/httpservice" "github.com/mattermost/mattermost-server/v6/services/imageproxy" "github.com/mattermost/mattermost-server/v6/services/remotecluster" @@ -6437,6 +6438,28 @@ func (a *OpenTracingAppLayer) GetGroupMemberUsersPage(groupID string, page int, return resultVar0, resultVar1, resultVar2 } +func (a *OpenTracingAppLayer) GetGroupMemberUsersSortedPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions, teammateNameDisplay string) ([]*model.User, int, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetGroupMemberUsersSortedPage") + + 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, resultVar2 := a.app.GetGroupMemberUsersSortedPage(groupID, page, perPage, viewRestrictions, teammateNameDisplay) + + if resultVar2 != nil { + span.LogFields(spanlog.Error(resultVar2)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1, resultVar2 +} + func (a *OpenTracingAppLayer) GetGroupSyncable(groupID string, syncableID string, syncableType model.GroupSyncableType) (*model.GroupSyncable, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetGroupSyncable") @@ -10305,7 +10328,7 @@ func (a *OpenTracingAppLayer) GetTotalUsersStats(viewRestrictions *model.ViewUse return resultVar0, resultVar1 } -func (a *OpenTracingAppLayer) GetUploadSession(uploadId string) (*model.UploadSession, *model.AppError) { +func (a *OpenTracingAppLayer) GetUploadSession(c request.CTX, uploadId string) (*model.UploadSession, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetUploadSession") @@ -10317,7 +10340,7 @@ func (a *OpenTracingAppLayer) GetUploadSession(uploadId string) (*model.UploadSe }() defer span.Finish() - resultVar0, resultVar1 := a.app.GetUploadSession(uploadId) + resultVar0, resultVar1 := a.app.GetUploadSession(c, uploadId) if resultVar1 != nil { span.LogFields(spanlog.Error(resultVar1)) @@ -11559,6 +11582,23 @@ func (a *OpenTracingAppLayer) HasSharedChannel(channelID string) (bool, error) { return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) HooksManager() *product.HooksManager { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.HooksManager") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.HooksManager() + + return resultVar0 +} + func (a *OpenTracingAppLayer) HubRegister(webConn *platform.WebConn) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.HubRegister") @@ -12632,6 +12672,21 @@ func (a *OpenTracingAppLayer) NotifyAndSetWarnMetricAck(warnMetricId string, sen return resultVar0 } +func (a *OpenTracingAppLayer) NotifySelfHostedSignupProgress(progress string, userId string) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.NotifySelfHostedSignupProgress") + + 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.NotifySelfHostedSignupProgress(progress, userId) +} + func (a *OpenTracingAppLayer) NotifySessionsExpired() error { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.NotifySessionsExpired") @@ -18135,7 +18190,7 @@ func (a *OpenTracingAppLayer) UpdateWebConnUserActivity(session model.Session, a a.app.UpdateWebConnUserActivity(session, activityAt) } -func (a *OpenTracingAppLayer) UploadData(c *request.Context, us *model.UploadSession, rd io.Reader) (*model.FileInfo, *model.AppError) { +func (a *OpenTracingAppLayer) UploadData(c request.CTX, us *model.UploadSession, rd io.Reader) (*model.FileInfo, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.UploadData") @@ -18499,6 +18554,28 @@ func (a *OpenTracingAppLayer) WriteFile(fr io.Reader, path string) (int64, *mode return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) WriteFileContext(ctx context.Context, fr io.Reader, path string) (int64, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.WriteFileContext") + + 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.WriteFileContext(ctx, fr, path) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func NewOpenTracingAppLayer(childApp app.AppIface, ctx context.Context) *OpenTracingAppLayer { newApp := OpenTracingAppLayer{ app: childApp, diff --git a/app/permissions_migrations.go b/app/permissions_migrations.go index 34661b5d11..3bd85521fe 100644 --- a/app/permissions_migrations.go +++ b/app/permissions_migrations.go @@ -994,6 +994,30 @@ func (a *App) getAddCustomUserGroupsPermissions() (permissionsMap, error) { return t, nil } +func (a *App) getAddCustomUserGroupsPermissionRestore() (permissionsMap, error) { + t := []permissionTransformation{} + + customGroupPermissions := []string{ + model.PermissionRestoreCustomGroup.Id, + } + + t = append(t, permissionTransformation{ + On: isExactRole(model.SystemUserRoleId), + Add: customGroupPermissions, + }) + + t = append(t, permissionTransformation{ + On: isExactRole(model.SystemAdminRoleId), + Add: customGroupPermissions, + }) + + t = append(t, permissionTransformation{ + On: isExactRole(model.SystemCustomGroupAdminRoleId), + Add: customGroupPermissions, + }) + return t, nil +} + func (a *App) getAddPlaybooksPermissions() (permissionsMap, error) { transformations := []permissionTransformation{} @@ -1110,6 +1134,7 @@ func (s *Server) doPermissionsMigrations() error { {Key: model.MigrationKeyAddCustomUserGroupsPermissions, Migration: a.getAddCustomUserGroupsPermissions}, {Key: model.MigrationKeyAddPlayboosksManageRolesPermissions, Migration: a.getPlaybooksPermissionsAddManageRoles}, {Key: model.MigrationKeyAddProductsBoardsPermissions, Migration: a.getProductsBoardsPermissions}, + {Key: model.MigrationKeyAddCustomUserGroupsPermissionRestore, Migration: a.getAddCustomUserGroupsPermissionRestore}, } roles, err := s.Store().Role().GetAll() diff --git a/app/platform/config.go b/app/platform/config.go index fd5bb05f76..e8eb425b96 100644 --- a/app/platform/config.go +++ b/app/platform/config.go @@ -75,12 +75,6 @@ func (ps *PlatformService) SaveConfig(newCfg *model.Config, sendConfigChangeClus return nil, nil, model.NewAppError("saveConfig", "app.save_config.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } - if ps.startMetrics && *ps.Config().MetricsSettings.Enable { - ps.RestartMetrics() - } else { - ps.ShutdownMetrics() - } - if ps.clusterIFace != nil { err := ps.clusterIFace.ConfigChanged(ps.configStore.RemoveEnvironmentOverrides(oldCfg), ps.configStore.RemoveEnvironmentOverrides(newCfg), sendConfigChangeClusterMessage) diff --git a/app/platform/config_test.go b/app/platform/config_test.go index af99b47d89..8268b32929 100644 --- a/app/platform/config_test.go +++ b/app/platform/config_test.go @@ -70,4 +70,31 @@ func TestConfigSave(t *testing.T) { updatedCfg := th.Service.Config() assert.Equal(t, "http://newhost.me", *updatedCfg.ServiceSettings.SiteURL) }) + + t.Run("do not restart the metrics server on a different type of config change", func(t *testing.T) { + th := Setup(t, StartMetrics()) + defer th.TearDown() + + metricsMock := &mocks.MetricsInterface{} + metricsMock.On("IncrementWebsocketEvent", mock.AnythingOfType("string")).Return() + metricsMock.On("IncrementWebSocketBroadcastBufferSize", mock.AnythingOfType("string"), mock.AnythingOfType("float64")).Return() + metricsMock.On("DecrementWebSocketBroadcastBufferSize", mock.AnythingOfType("string"), mock.AnythingOfType("float64")).Return() + metricsMock.On("Register").Return() + th.Service.metricsIFace = metricsMock + + // Change a random config setting + cfg := th.Service.Config().Clone() + cfg.ThemeSettings.EnableThemeSelection = model.NewBool(!*cfg.ThemeSettings.EnableThemeSelection) + th.Service.SaveConfig(cfg, false) + metricsMock.AssertNumberOfCalls(t, "Register", 0) + + // Disable metrics + cfg.MetricsSettings.Enable = model.NewBool(false) + th.Service.SaveConfig(cfg, false) + + // Change the metrics setting + cfg.MetricsSettings.Enable = model.NewBool(true) + th.Service.SaveConfig(cfg, false) + metricsMock.AssertNumberOfCalls(t, "Register", 1) + }) } diff --git a/app/platform/service.go b/app/platform/service.go index b2f02c2c77..857e14e3c8 100644 --- a/app/platform/service.go +++ b/app/platform/service.go @@ -268,6 +268,14 @@ func New(sc ServiceConfig, options ...Option) (*PlatformService, error) { if mErr := ps.resetMetrics(); mErr != nil { return nil, mErr } + + ps.configStore.AddListener(func(oldCfg, newCfg *model.Config) { + if *oldCfg.MetricsSettings.Enable != *newCfg.MetricsSettings.Enable || *oldCfg.MetricsSettings.ListenAddress != *newCfg.MetricsSettings.ListenAddress { + if mErr := ps.resetMetrics(); mErr != nil { + mlog.Warn("Failed to reset metrics", mlog.Err(mErr)) + } + } + }) } // Step 9: Init AsymmetricSigningKey depends on step 6 (store) diff --git a/app/platform/service_test.go b/app/platform/service_test.go index 11633b2d5b..8654b18685 100644 --- a/app/platform/service_test.go +++ b/app/platform/service_test.go @@ -105,10 +105,9 @@ func TestMetrics(t *testing.T) { // there is no config listener for the metrics // we handle it on config save step - th.Service.UpdateConfig(func(c *model.Config) { - c.MetricsSettings.Enable = model.NewBool(true) - }) - th.Service.SaveConfig(th.Service.Config(), false) + cfg := th.Service.Config().Clone() + cfg.MetricsSettings.Enable = model.NewBool(true) + th.Service.SaveConfig(cfg, false) require.NotNil(t, th.Service.metrics) metricsAddr := strings.Replace(th.Service.metrics.listenAddr, "[::]", "http://localhost", 1) @@ -117,17 +116,14 @@ func TestMetrics(t *testing.T) { require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) - th.Service.UpdateConfig(func(c *model.Config) { - c.MetricsSettings.Enable = model.NewBool(false) - }) - th.Service.SaveConfig(th.Service.Config(), false) + cfg.MetricsSettings.Enable = model.NewBool(false) + th.Service.SaveConfig(cfg, false) _, err = http.Get(metricsAddr) require.Error(t, err) }) t.Run("ensure the metrics server is started with advanced metrics", func(t *testing.T) { - t.Skip("MM-47635") th := Setup(t, StartMetrics()) defer th.TearDown() diff --git a/app/plugin_api.go b/app/plugin_api.go index accedf37ba..5c8ee9fad1 100644 --- a/app/plugin_api.go +++ b/app/plugin_api.go @@ -1255,7 +1255,9 @@ func (api *PluginAPI) UploadData(us *model.UploadSession, rd io.Reader) (*model. } func (api *PluginAPI) GetUploadSession(uploadID string) (*model.UploadSession, error) { - fi, err := api.app.GetUploadSession(uploadID) + // We want to fetch from master DB to avoid a potential read-after-write on the plugin side. + api.ctx.SetContext(WithMaster(api.ctx.Context())) + fi, err := api.app.GetUploadSession(api.ctx, uploadID) if err != nil { return nil, err } diff --git a/app/plugin_db_driver.go b/app/plugin_db_driver.go index 753bd0671c..a29fa7467f 100644 --- a/app/plugin_db_driver.go +++ b/app/plugin_db_driver.go @@ -8,6 +8,7 @@ import ( "database/sql" "database/sql/driver" "sync" + "time" "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin" @@ -44,7 +45,10 @@ func (d *DriverImpl) Conn(isMaster bool) (string, error) { if !isMaster { dbFunc = d.s.Platform().Store.GetInternalReplicaDB } - conn, err := dbFunc().Conn(context.Background()) + timeout := time.Duration(*d.s.Config().SqlSettings.QueryTimeout) * time.Second + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + conn, err := dbFunc().Conn(ctx) if err != nil { return "", err } diff --git a/app/plugin_db_driver_test.go b/app/plugin_db_driver_test.go new file mode 100644 index 0000000000..a2c428fb6f --- /dev/null +++ b/app/plugin_db_driver_test.go @@ -0,0 +1,21 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConnCreateTimeout(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + *th.App.Config().SqlSettings.QueryTimeout = 0 + + d := NewDriverImpl(th.Server) + _, err := d.Conn(true) + require.Error(t, err) +} diff --git a/app/product.go b/app/product.go index a8c15a9e0a..6ab3719c9a 100644 --- a/app/product.go +++ b/app/product.go @@ -6,31 +6,20 @@ package app import ( "fmt" "strings" + + "github.com/mattermost/mattermost-server/v6/product" ) -type Product interface { - Start() error - Stop() error -} - -type ProductManifest struct { - Initializer func(*Server, map[ServiceKey]any) (Product, error) - Dependencies map[ServiceKey]struct{} -} - -var products = make(map[string]ProductManifest) - -func RegisterProduct(name string, m ProductManifest) { - products[name] = m -} - func (s *Server) initializeProducts( - productMap map[string]ProductManifest, - serviceMap map[ServiceKey]any, + productMap map[string]product.Manifest, + serviceMap map[product.ServiceKey]any, ) error { // create a product map to consume pmap := make(map[string]struct{}) for name := range productMap { + if !s.shouldStart(name) { + continue + } pmap[name] = struct{}{} } @@ -57,7 +46,7 @@ func (s *Server) initializeProducts( // some products can register themselves/their services initializer := manifest.Initializer - prod, err := initializer(s, serviceMap) + prod, err := initializer(serviceMap) if err != nil { return fmt.Errorf("error initializing product %q: %w", product, err) } @@ -78,3 +67,11 @@ func (s *Server) initializeProducts( return nil } + +func (s *Server) shouldStart(product string) bool { + if !s.Config().FeatureFlags.BoardsProduct && product == "boards" { + return false + } + + return true +} diff --git a/app/product_test.go b/app/product_test.go index bafba19cbd..ad5c6c1be7 100644 --- a/app/product_test.go +++ b/app/product_test.go @@ -6,6 +6,9 @@ package app import ( "testing" + "github.com/mattermost/mattermost-server/v6/app/platform" + "github.com/mattermost/mattermost-server/v6/config" + "github.com/mattermost/mattermost-server/v6/product" "github.com/stretchr/testify/require" ) @@ -16,7 +19,7 @@ const ( type productA struct{} -func newProductA(s *Server, m map[ServiceKey]any) (Product, error) { +func newProductA(m map[product.ServiceKey]any) (product.Product, error) { m[testSrvKey1] = nil return &productA{}, nil } @@ -26,7 +29,7 @@ func (p *productA) Stop() error { return nil } type productB struct{} -func newProductB(s *Server, m map[ServiceKey]any) (Product, error) { +func newProductB(m map[product.ServiceKey]any) (product.Product, error) { m[testSrvKey2] = nil return &productB{}, nil } @@ -35,74 +38,80 @@ func (p *productB) Start() error { return nil } func (p *productB) Stop() error { return nil } func TestInitializeProducts(t *testing.T) { + ps, err := platform.New(platform.ServiceConfig{ConfigStore: config.NewTestMemoryStore()}) + require.NoError(t, err) + t.Run("2 products and no circular dependency", func(t *testing.T) { - serviceMap := map[ServiceKey]any{ - ConfigKey: nil, - LicenseKey: nil, - FilestoreKey: nil, - ClusterKey: nil, + serviceMap := map[product.ServiceKey]any{ + product.ConfigKey: nil, + product.LicenseKey: nil, + product.FilestoreKey: nil, + product.ClusterKey: nil, } - products := map[string]ProductManifest{ + products := map[string]product.Manifest{ "productA": { Initializer: newProductA, - Dependencies: map[ServiceKey]struct{}{ - ConfigKey: {}, - LicenseKey: {}, - FilestoreKey: {}, - ClusterKey: {}, + Dependencies: map[product.ServiceKey]struct{}{ + product.ConfigKey: {}, + product.LicenseKey: {}, + product.FilestoreKey: {}, + product.ClusterKey: {}, }, }, "productB": { Initializer: newProductB, - Dependencies: map[ServiceKey]struct{}{ - ConfigKey: {}, - testSrvKey1: {}, - FilestoreKey: {}, - ClusterKey: {}, + Dependencies: map[product.ServiceKey]struct{}{ + product.ConfigKey: {}, + testSrvKey1: {}, + product.FilestoreKey: {}, + product.ClusterKey: {}, }, }, } + server := &Server{ - products: make(map[string]Product), + products: make(map[string]product.Product), + platform: ps, } - err := server.initializeProducts(products, serviceMap) + err = server.initializeProducts(products, serviceMap) require.NoError(t, err) require.Len(t, server.products, 2) }) t.Run("2 products and circular dependency", func(t *testing.T) { - serviceMap := map[ServiceKey]any{ - ConfigKey: nil, - LicenseKey: nil, - FilestoreKey: nil, - ClusterKey: nil, + serviceMap := map[product.ServiceKey]any{ + product.ConfigKey: nil, + product.LicenseKey: nil, + product.FilestoreKey: nil, + product.ClusterKey: nil, } - products := map[string]ProductManifest{ + products := map[string]product.Manifest{ "productA": { Initializer: newProductA, - Dependencies: map[ServiceKey]struct{}{ - ConfigKey: {}, - LicenseKey: {}, - FilestoreKey: {}, - ClusterKey: {}, - testSrvKey2: {}, + Dependencies: map[product.ServiceKey]struct{}{ + product.ConfigKey: {}, + product.LicenseKey: {}, + product.FilestoreKey: {}, + product.ClusterKey: {}, + testSrvKey2: {}, }, }, "productB": { Initializer: newProductB, - Dependencies: map[ServiceKey]struct{}{ - ConfigKey: {}, - testSrvKey1: {}, - FilestoreKey: {}, - ClusterKey: {}, + Dependencies: map[product.ServiceKey]struct{}{ + product.ConfigKey: {}, + testSrvKey1: {}, + product.FilestoreKey: {}, + product.ClusterKey: {}, }, }, } server := &Server{ - products: make(map[string]Product), + products: make(map[string]product.Product), + platform: ps, } err := server.initializeProducts(products, serviceMap) @@ -110,19 +119,19 @@ func TestInitializeProducts(t *testing.T) { }) t.Run("2 products and one w/o any dependency", func(t *testing.T) { - serviceMap := map[ServiceKey]any{ - ConfigKey: nil, - LicenseKey: nil, - FilestoreKey: nil, - ClusterKey: nil, + serviceMap := map[product.ServiceKey]any{ + product.ConfigKey: nil, + product.LicenseKey: nil, + product.FilestoreKey: nil, + product.ClusterKey: nil, } - products := map[string]ProductManifest{ + products := map[string]product.Manifest{ "productA": { Initializer: newProductA, - Dependencies: map[ServiceKey]struct{}{ - ConfigKey: {}, - LicenseKey: {}, + Dependencies: map[product.ServiceKey]struct{}{ + product.ConfigKey: {}, + product.LicenseKey: {}, }, }, "productB": { @@ -130,11 +139,32 @@ func TestInitializeProducts(t *testing.T) { }, } server := &Server{ - products: make(map[string]Product), + products: make(map[string]product.Product), + platform: ps, } err := server.initializeProducts(products, serviceMap) require.NoError(t, err) require.Len(t, server.products, 2) }) + + t.Run("boards product to be blocked", func(t *testing.T) { + products := map[string]product.Manifest{ + "productA": { + Initializer: newProductA, + }, + "boards": { + Initializer: newProductB, + }, + } + + server := &Server{ + products: make(map[string]product.Product), + platform: ps, + } + + err := server.initializeProducts(products, map[product.ServiceKey]any{}) + require.NoError(t, err) + require.Len(t, server.products, 1) + }) } diff --git a/app/server.go b/app/server.go index d39e66705c..fbd433a6db 100644 --- a/app/server.go +++ b/app/server.go @@ -75,30 +75,6 @@ import ( // declaring this as var to allow overriding in tests var SentryDSN = "placeholder_sentry_dsn" -type ServiceKey string - -const ( - ChannelKey ServiceKey = "channel" - ConfigKey ServiceKey = "config" - LicenseKey ServiceKey = "license" - FilestoreKey ServiceKey = "filestore" - FileInfoStoreKey ServiceKey = "fileinfostore" - ClusterKey ServiceKey = "cluster" - CloudKey ServiceKey = "cloud" - PostKey ServiceKey = "post" - TeamKey ServiceKey = "team" - UserKey ServiceKey = "user" - PermissionsKey ServiceKey = "permissions" - RouterKey ServiceKey = "router" - BotKey ServiceKey = "bot" - LogKey ServiceKey = "log" - HooksKey ServiceKey = "hooks" - KVStoreKey ServiceKey = "kvstore" - StoreKey ServiceKey = "storekey" - SystemKey ServiceKey = "systemkey" - PreferencesKey ServiceKey = "preferenceskey" -) - type Server struct { // RootRouter is the starting point for all HTTP requests to the server. RootRouter *mux.Router @@ -160,7 +136,7 @@ type Server struct { tracer *tracing.Tracer - products map[string]Product + products map[string]product.Product hooksManager *product.HooksManager } @@ -187,7 +163,7 @@ func NewServer(options ...Option) (*Server, error) { RootRouter: rootRouter, LocalRouter: localRouter, timezones: timezones.New(), - products: make(map[string]Product), + products: make(map[string]product.Product), } for _, option := range options { @@ -262,24 +238,25 @@ func NewServer(options ...Option) (*Server, error) { // ensure app implements `product.UserService` var _ product.UserService = (*App)(nil) - serviceMap := map[ServiceKey]any{ - ChannelKey: &channelsWrapper{srv: s}, - ConfigKey: s.platform, - LicenseKey: s.licenseWrapper, - FilestoreKey: s.platform.FileBackend(), - FileInfoStoreKey: &fileInfoWrapper{srv: s}, - ClusterKey: s.platform, - UserKey: New(ServerConnector(s.Channels())), - LogKey: s.platform.Log(), - CloudKey: &cloudWrapper{cloud: s.Cloud}, - KVStoreKey: s.platform, - StoreKey: store.NewStoreServiceAdapter(s.Store()), - SystemKey: &systemServiceAdapter{server: s}, + serviceMap := map[product.ServiceKey]any{ + ServerKey: s, + product.ChannelKey: &channelsWrapper{srv: s}, + product.ConfigKey: s.platform, + product.LicenseKey: s.licenseWrapper, + product.FilestoreKey: s.platform.FileBackend(), + product.FileInfoStoreKey: &fileInfoWrapper{srv: s}, + product.ClusterKey: s.platform, + product.UserKey: New(ServerConnector(s.Channels())), + product.LogKey: s.platform.Log(), + product.CloudKey: &cloudWrapper{cloud: s.Cloud}, + product.KVStoreKey: s.platform, + product.StoreKey: store.NewStoreServiceAdapter(s.Store()), + product.SystemKey: &systemServiceAdapter{server: s}, } // Step 4: Initialize products. // Depends on s.httpService. - err = s.initializeProducts(products, serviceMap) + err = s.initializeProducts(product.GetProducts(), serviceMap) if err != nil { return nil, errors.Wrap(err, "failed to initialize products") } diff --git a/app/team_test.go b/app/team_test.go index 23fd15117b..e281a1feaa 100644 --- a/app/team_test.go +++ b/app/team_test.go @@ -102,6 +102,7 @@ func TestAddUserToTeam(t *testing.T) { }) t.Run("block user by domain but allow bot", func(t *testing.T) { + t.Skip("MM-48973") th.BasicTeam.AllowedDomains = "example.com" _, err := th.App.UpdateTeam(th.BasicTeam) require.Nil(t, err, "Should update the team") diff --git a/app/upload.go b/app/upload.go index 4da0b853b6..318e3ede89 100644 --- a/app/upload.go +++ b/app/upload.go @@ -48,7 +48,7 @@ func (a *App) genFileInfoFromReader(name string, file io.ReadSeeker, size int64) return info, nil } -func (a *App) runPluginsHook(c *request.Context, info *model.FileInfo, file io.Reader) *model.AppError { +func (a *App) runPluginsHook(c request.CTX, info *model.FileInfo, file io.Reader) *model.AppError { filePath := info.Path // using a pipe to avoid loading the whole file content in memory. r, w := io.Pipe() @@ -93,6 +93,7 @@ func (a *App) runPluginsHook(c *request.Context, info *model.FileInfo, file io.R if fileErr := a.RemoveFile(tmpPath); fileErr != nil { mlog.Warn("Failed to remove file", mlog.Err(fileErr)) } + r.CloseWithError(err) // always returns nil return err } @@ -154,8 +155,8 @@ func (a *App) CreateUploadSession(c request.CTX, us *model.UploadSession) (*mode return us, nil } -func (a *App) GetUploadSession(uploadId string) (*model.UploadSession, *model.AppError) { - us, err := a.Srv().Store().UploadSession().Get(uploadId) +func (a *App) GetUploadSession(c request.CTX, uploadId string) (*model.UploadSession, *model.AppError) { + us, err := a.Srv().Store().UploadSession().Get(c.Context(), uploadId) if err != nil { var nfErr *store.ErrNotFound switch { @@ -179,7 +180,7 @@ func (a *App) GetUploadSessionsForUser(userID string) ([]*model.UploadSession, * return uss, nil } -func (a *App) UploadData(c *request.Context, us *model.UploadSession, rd io.Reader) (*model.FileInfo, *model.AppError) { +func (a *App) UploadData(c request.CTX, us *model.UploadSession, rd io.Reader) (*model.FileInfo, *model.AppError) { // prevent more than one caller to upload data at the same time for a given upload session. // This is to avoid possible inconsistencies. a.ch.uploadLockMapMut.Lock() @@ -202,7 +203,8 @@ func (a *App) UploadData(c *request.Context, us *model.UploadSession, rd io.Read }() // fetch the session from store to check for inconsistencies. - if storedSession, err := a.GetUploadSession(us.Id); err != nil { + c.SetContext(WithMaster(c.Context())) + if storedSession, err := a.GetUploadSession(c, us.Id); err != nil { return nil, err } else if us.FileOffset != storedSession.FileOffset { return nil, model.NewAppError("UploadData", "app.upload.upload_data.concurrent.app_error", diff --git a/app/user.go b/app/user.go index a2f72eddb5..6e12bda34b 100644 --- a/app/user.go +++ b/app/user.go @@ -316,10 +316,13 @@ func (a *App) createUserOrGuest(c request.CTX, user *model.User, guest bool) (*m }, plugin.UserHasBeenCreatedID) }) - _, cwsErr := a.SendSubscriptionHistoryEvent(ruser.Id) - if cwsErr != nil { - c.Logger().Error("Failed to create/update the SubscriptionHistoryEvent", mlog.Err(cwsErr)) - } + // Create/Update the subscriptionHistoryEvent + go func() { + _, err := a.SendSubscriptionHistoryEvent(ruser.Id) + if err != nil { + c.Logger().Error("Failed to create/update the SubscriptionHistoryEvent", mlog.Err(err)) + } + }() return ruser, nil } diff --git a/app/user_test.go b/app/user_test.go index 80a9ffd77d..788ae11275 100644 --- a/app/user_test.go +++ b/app/user_test.go @@ -1877,7 +1877,6 @@ func TestSendSubscriptionHistoryEvent(t *testing.T) { CreateAt: 1000000000, Seats: 10, DNS: "some.dns.server", - IsPaidTier: "false", } subscriptionHistory := &model.SubscriptionHistory{ diff --git a/einterfaces/cloud.go b/einterfaces/cloud.go index 46f5783225..d58e8c92dd 100644 --- a/einterfaces/cloud.go +++ b/einterfaces/cloud.go @@ -33,7 +33,15 @@ type CloudInterface interface { GetLicenseRenewalStatus(userID, token string) error InvalidateCaches() error + // hosted customer methods + SelfHostedSignupAvailable() error BootstrapSelfHostedSignup(req model.BootstrapSelfHostedSignupRequest) (*model.BootstrapSelfHostedSignupResponse, error) + CreateCustomerSelfHostedSignup(req model.SelfHostedCustomerForm, requesterEmail string) (*model.SelfHostedSignupCustomerResponse, error) + ConfirmSelfHostedSignup(req model.SelfHostedConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error) + ConfirmSelfHostedSignupLicenseApplication() error + GetSelfHostedInvoices() ([]*model.Invoice, error) + GetSelfHostedInvoicePDF(invoiceID string) ([]byte, string, error) + CreateOrUpdateSubscriptionHistoryEvent(userID string, userCount int) (*model.SubscriptionHistory, error) HandleLicenseChange() error } diff --git a/einterfaces/mocks/CloudInterface.go b/einterfaces/mocks/CloudInterface.go index 141823c834..4b89135dc0 100644 --- a/einterfaces/mocks/CloudInterface.go +++ b/einterfaces/mocks/CloudInterface.go @@ -74,6 +74,43 @@ func (_m *CloudInterface) ConfirmCustomerPayment(userID string, confirmRequest * return r0 } +// ConfirmSelfHostedSignup provides a mock function with given fields: req, requesterEmail +func (_m *CloudInterface) ConfirmSelfHostedSignup(req model.SelfHostedConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error) { + ret := _m.Called(req, requesterEmail) + + var r0 *model.SelfHostedSignupConfirmResponse + if rf, ok := ret.Get(0).(func(model.SelfHostedConfirmPaymentMethodRequest, string) *model.SelfHostedSignupConfirmResponse); ok { + r0 = rf(req, requesterEmail) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.SelfHostedSignupConfirmResponse) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(model.SelfHostedConfirmPaymentMethodRequest, string) error); ok { + r1 = rf(req, requesterEmail) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ConfirmSelfHostedSignupLicenseApplication provides a mock function with given fields: +func (_m *CloudInterface) ConfirmSelfHostedSignupLicenseApplication() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + // CreateCustomerPayment provides a mock function with given fields: userID func (_m *CloudInterface) CreateCustomerPayment(userID string) (*model.StripeSetupIntent, error) { ret := _m.Called(userID) @@ -97,6 +134,29 @@ func (_m *CloudInterface) CreateCustomerPayment(userID string) (*model.StripeSet return r0, r1 } +// CreateCustomerSelfHostedSignup provides a mock function with given fields: req, requesterEmail +func (_m *CloudInterface) CreateCustomerSelfHostedSignup(req model.SelfHostedCustomerForm, requesterEmail string) (*model.SelfHostedSignupCustomerResponse, error) { + ret := _m.Called(req, requesterEmail) + + var r0 *model.SelfHostedSignupCustomerResponse + if rf, ok := ret.Get(0).(func(model.SelfHostedCustomerForm, string) *model.SelfHostedSignupCustomerResponse); ok { + r0 = rf(req, requesterEmail) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.SelfHostedSignupCustomerResponse) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(model.SelfHostedCustomerForm, string) error); ok { + r1 = rf(req, requesterEmail) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // CreateOrUpdateSubscriptionHistoryEvent provides a mock function with given fields: userID, userCount func (_m *CloudInterface) CreateOrUpdateSubscriptionHistoryEvent(userID string, userCount int) (*model.SubscriptionHistory, error) { ret := _m.Called(userID, userCount) @@ -279,6 +339,59 @@ func (_m *CloudInterface) GetLicenseRenewalStatus(userID string, token string) e return r0 } +// GetSelfHostedInvoicePDF provides a mock function with given fields: invoiceID +func (_m *CloudInterface) GetSelfHostedInvoicePDF(invoiceID string) ([]byte, string, error) { + ret := _m.Called(invoiceID) + + var r0 []byte + if rf, ok := ret.Get(0).(func(string) []byte); ok { + r0 = rf(invoiceID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + var r1 string + if rf, ok := ret.Get(1).(func(string) string); ok { + r1 = rf(invoiceID) + } else { + r1 = ret.Get(1).(string) + } + + var r2 error + if rf, ok := ret.Get(2).(func(string) error); ok { + r2 = rf(invoiceID) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// GetSelfHostedInvoices provides a mock function with given fields: +func (_m *CloudInterface) GetSelfHostedInvoices() ([]*model.Invoice, error) { + ret := _m.Called() + + var r0 []*model.Invoice + if rf, ok := ret.Get(0).(func() []*model.Invoice); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Invoice) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetSelfHostedProducts provides a mock function with given fields: userID func (_m *CloudInterface) GetSelfHostedProducts(userID string) ([]*model.Product, error) { ret := _m.Called(userID) @@ -376,6 +489,20 @@ func (_m *CloudInterface) RequestCloudTrial(userID string, subscriptionID string return r0, r1 } +// SelfHostedSignupAvailable provides a mock function with given fields: +func (_m *CloudInterface) SelfHostedSignupAvailable() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + // UpdateCloudCustomer provides a mock function with given fields: userID, customerInfo func (_m *CloudInterface) UpdateCloudCustomer(userID string, customerInfo *model.CloudCustomerInfo) (*model.CloudCustomer, error) { ret := _m.Called(userID, customerInfo) diff --git a/i18n/de.json b/i18n/de.json index 6b8f7e7db0..7daafc82da 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -9170,10 +9170,6 @@ "id": "api.templates.cloud_upgrade_confirmation.title", "translation": "Du bist jetzt upgegraded worden!" }, - { - "id": "api.templates.cloud_upgrade_confirmation.subtitle", - "translation": "Dein {{.WorkspaceName}} Arbeitsbereich wurde jetzt aktualisiert. Dein {{.WorkspaceName}} wird ab {{.Date}} abgerechnet" - }, { "id": "api.templates.cloud_upgrade_confirmation.subject", "translation": "Mattermost Upgrade Bestätigung" @@ -9300,7 +9296,7 @@ }, { "id": "api.templates.delinquency_75.subtitle1", - "translation": "Dies ist eine letzte Erinnerung, dass wir seit {{.DelinquencyDate}} keine Zahlung für deinen Mattermost Cloud-Arbeitsbereich erhalten haben" + "translation": "Dies ist eine letzte Erinnerung, dass wir seit {{.DelinquencyDate}} keine Zahlung für deinen Mattermost Cloud-Arbeitsbereich erhalten haben." }, { "id": "api.templates.delinquency_75.subject", @@ -9324,7 +9320,7 @@ }, { "id": "api.templates.delinquency_7.subtitle1", - "translation": "Wir konnten deine letzte Zahlung nicht bearbeiten" + "translation": "Wir konnten deine letzte Zahlung nicht bearbeiten." }, { "id": "api.templates.delinquency_7.button", @@ -9372,7 +9368,7 @@ }, { "id": "api.templates.delinquency_45.subtitle1", - "translation": "Wir waren nicht in der Lage, ausstehende Rechnungen mit Datum {{.DelinquencyDate}} zu begleichen. Dein Arbeitsbereich ist in Gefahr heruntergestuft zu werden." + "translation": "Wir waren nicht in der Lage, ausstehende Rechnungen seit {{.DelinquencyDate}} zu begleichen. Dein Arbeitsbereich ist in Gefahr heruntergestuft zu werden." }, { "id": "api.templates.delinquency_45.subject", @@ -9436,7 +9432,7 @@ }, { "id": "api.templates.delinquency_14.subject", - "translation": "Bezahlung ist überfällig für deinen Mattermost {{.Plan}}." + "translation": "Bezahlung ist überfällig für deinen Mattermost {{.Plan}}" }, { "id": "api.templates.delinquency_14.button", @@ -9565,5 +9561,177 @@ { "id": "api.admin.syncables_error", "translation": "Fehlschlag beim Hinzufügen des Benutzers zu Gruppen-Teams und -Kanälen" + }, + { + "id": "model.draft.is_valid.user_id.app_error", + "translation": "Ungültige Benutzer-ID." + }, + { + "id": "model.draft.is_valid.update_at.app_error", + "translation": "Aktualisiert am muss eine gültige Zeit sein." + }, + { + "id": "model.draft.is_valid.root_id.app_error", + "translation": "Ungültige Root-ID." + }, + { + "id": "model.draft.is_valid.props.app_error", + "translation": "Ungültige Eigenschaften." + }, + { + "id": "model.draft.is_valid.msg.app_error", + "translation": "Ungültige Nachricht." + }, + { + "id": "model.draft.is_valid.file_ids.app_error", + "translation": "Ungültige Datei-IDs." + }, + { + "id": "model.draft.is_valid.create_at.app_error", + "translation": "Erstellt am muss eine gültige Zeit sein." + }, + { + "id": "model.draft.is_valid.channel_id.app_error", + "translation": "Ungültige Kanal-ID." + }, + { + "id": "model.acknowledgement.is_valid.user_id.app_error", + "translation": "Ungültige Benutzer-ID." + }, + { + "id": "model.acknowledgement.is_valid.post_id.app_error", + "translation": "Ungültige Nachrichten-ID." + }, + { + "id": "app.post_prority.get_for_post.app_error", + "translation": "Die Priorität der Nachricht kann nicht ermittelt werden" + }, + { + "id": "app.draft.update.app_error", + "translation": "Die Aktualisierung des Entwurfs ist nicht möglich." + }, + { + "id": "app.draft.save.app_error", + "translation": "Der Entwurf kann nicht gespeichert werden." + }, + { + "id": "app.draft.get_for_draft.app_error", + "translation": "Es können keine Dateien für den Entwurf abgerufen werden." + }, + { + "id": "app.draft.get_drafts.app_error", + "translation": "Die Entwürfe des Benutzers können nicht abgerufen werden." + }, + { + "id": "app.draft.get.app_error", + "translation": "Der Entwurf kann nicht abgerufen werden." + }, + { + "id": "app.draft.feature_disabled", + "translation": "Die Funktion Entwürfe ist deaktiviert." + }, + { + "id": "app.draft.delete.app_error", + "translation": "Der Entwurf kann nicht gelöscht werden." + }, + { + "id": "app.channel.get_priority_for_posts.app_error", + "translation": "Die Priorität der Nachrichten kann nicht ermittelt werden" + }, + { + "id": "app.channel.count_urgent_posts_since.app_error", + "translation": "Es ist nicht möglich, dringende Nachrichten seit dem angegebenen Datum zu zählen." + }, + { + "id": "app.acknowledgement.save.save.app_error", + "translation": "Die Bestätigung für die Nachricht kann nicht gespeichert werden." + }, + { + "id": "app.acknowledgement.getforpost.get.app_error", + "translation": "Keine Bestätigung für den Beitrag erhalten." + }, + { + "id": "app.acknowledgement.get.app_error", + "translation": "Bestätigung nicht möglich." + }, + { + "id": "app.acknowledgement.delete.app_error", + "translation": "Bestätigung kann nicht gelöscht werden." + }, + { + "id": "api.upload.create.upload_too_large.app_error", + "translation": "Konnte Datei nicht hochladen. Datei ist zu groß." + }, + { + "id": "api.drafts.disabled.app_error", + "translation": "Die Funktion Entwürfe ist deaktiviert." + }, + { + "id": "api.draft.create_draft.can_not_draft_to_deleted.error", + "translation": "Entwurf kann nicht in einem gelöschten Kanal gespeichert werden" + }, + { + "id": "api.acknowledgement.save.archived_channel.app_error", + "translation": "Du kannst in einem archivierten Kanal nicht bestätigen." + }, + { + "id": "api.acknowledgement.delete.deadline.app_error", + "translation": "Du kannst eine Bestätigung nach Ablauf von 5 Minuten nicht mehr löschen." + }, + { + "id": "api.acknowledgement.delete.archived_channel.app_error", + "translation": "Du kannst eine Bestätigung in einem archivierten Kanal nicht entfernen." + }, + { + "id": "worktemplate.product_teams.feature_release.description.channel", + "translation": "Chatten mit deinem Team in einem Feature-Release-Kanal, der sich problemlos mit deinen Boards, Playbooks und App-Bots verbinden lässt." + }, + { + "id": "worktemplate.product_teams.feature_release.description.board", + "translation": "Verwende unsere Vorlage für die Besprechungsagenda für wiederkehrende Besprechungen wie z. B. Standup-Meetings und unsere Projektaufgabentafel, um den Fortschritt der Aufgaben zu verwalten." + }, + { + "id": "worktemplate.category.product_teams", + "translation": "Produkt-Teams" + }, + { + "id": "model.draft.is_valid.priority.app_error", + "translation": "Ungültige Priorität" + }, + { + "id": "app.worktemplates.get_templates.app_error", + "translation": "Arbeitsvorlagen können nicht abgerufen werden" + }, + { + "id": "app.worktemplates.get_categories.app_error", + "translation": "Arbeitsvorlagenkategorien können nicht abgerufen werden" + }, + { + "id": "worktemplate.product_teams.feature_release.description.playbook", + "translation": "Erstelle transparente Arbeitsabläufe zwischen den Entwicklungsteams, um einen nahtlosen Entwicklungsprozess zu gewährleisten." + }, + { + "id": "worktemplate.product_teams.feature_release.description.integration", + "translation": "Steigere die Produktivität in deinem Kanal durch die Integration eines Jira-Bots und eines Github-Bots. Diese werden für dich heruntergeladen." + }, + { + "id": "api.templates.cloud_welcome_email.yearly_plan_button", + "translation": "Deine Rechnung einsehen" + }, + { + "id": "api.templates.cloud_upgrade_confirmation_yearly.subtitle", + "translation": "Dein {{.WorkspaceName}} Arbeitsbereich wurde jetzt hochgestuft." + }, + { + "id": "api.templates.cloud_upgrade_confirmation_monthly.subtitle", + "translation": "Dein {{.WorkspaceName}} Arbeitsbereich wurde jetzt hochgestuft. Du wirst ab dem {{.Date}} abgerechnet" + }, + { + "id": "api.user.get_users.validation.app_error", + "translation": "Fehler beim Abrufen von Rollen während der Validierung." + }, + { + "id": "api.server.hosted_signup_unavailable.error", + "translation": "Das Portal ist für selbst gehostete Anmeldungen nicht verfügbar." } ] diff --git a/i18n/en.json b/i18n/en.json index 43b65baa82..25f8a93b61 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2543,6 +2543,10 @@ "id": "api.scheme.patch_scheme.license.error", "translation": "Your license does not support update permissions schemes" }, + { + "id": "api.server.hosted_signup_unavailable.error", + "translation": "Portal unavailable for self-hosted signup." + }, { "id": "api.server.license_up_for_renewal.error_generating_link", "translation": "Failed to generate the license renewal link" @@ -4131,6 +4135,10 @@ "id": "api.user.get_user_by_email.permissions.app_error", "translation": "Unable to get user by email." }, + { + "id": "api.user.get_users.validation.app_error", + "translation": "Error fetching roles during validation." + }, { "id": "api.user.invalidate_verify_email_tokens.error", "translation": "Unable to get tokens by type when invalidating email verification tokens" diff --git a/i18n/en_AU.json b/i18n/en_AU.json index 169abdd703..3283b75397 100644 --- a/i18n/en_AU.json +++ b/i18n/en_AU.json @@ -9166,10 +9166,6 @@ "id": "api.templates.cloud_upgrade_confirmation.title", "translation": "You are now upgraded!" }, - { - "id": "api.templates.cloud_upgrade_confirmation.subtitle", - "translation": "Your {{.WorkspaceName}} workspace has now been upgraded. You'll be billed from {{.Date}}" - }, { "id": "api.templates.cloud_upgrade_confirmation.subject", "translation": "Mattermost Upgrade Confirmation" @@ -9328,7 +9324,7 @@ }, { "id": "api.templates.delinquency_75.subtitle1", - "translation": "This is a final reminder that payment for your Mattermost Cloud workspace hasn't been received since {{.DelinquencyDate}}" + "translation": "This is a final reminder that payment for your Mattermost Cloud workspace hasn't been received since {{.DelinquencyDate}}." }, { "id": "api.templates.delinquency_75.subject", @@ -9348,7 +9344,7 @@ }, { "id": "api.templates.delinquency_7.subtitle1", - "translation": "Your most recent payment couldn't be processed" + "translation": "Your most recent payment couldn't be processed." }, { "id": "api.templates.delinquency_7.button", @@ -9396,7 +9392,7 @@ }, { "id": "api.templates.delinquency_45.subtitle1", - "translation": "Payment for outstanding invoices dated {{.DelinquencyDate}} have not been able to be collected. Your workspace is at risk of being downgraded." + "translation": "Payment for outstanding invoices since {{.DelinquencyDate}} have not been able to be collected. Your workspace is at risk of being downgraded." }, { "id": "api.templates.delinquency_45.subject", @@ -9456,7 +9452,7 @@ }, { "id": "api.templates.delinquency_14.subject", - "translation": "Payment is overdue for your Mattermost {{.Plan}}." + "translation": "Payment is overdue for your Mattermost {{.Plan}}" }, { "id": "api.templates.delinquency_14.button", @@ -9569,5 +9565,133 @@ { "id": "api.admin.syncables_error", "translation": "Failed to add user to group-teams and group-channels" + }, + { + "id": "model.draft.is_valid.user_id.app_error", + "translation": "Invalid user ID." + }, + { + "id": "model.draft.is_valid.update_at.app_error", + "translation": "Update at must be a valid time." + }, + { + "id": "model.draft.is_valid.root_id.app_error", + "translation": "Invalid root ID." + }, + { + "id": "model.draft.is_valid.props.app_error", + "translation": "Invalid props." + }, + { + "id": "model.draft.is_valid.msg.app_error", + "translation": "Invalid message." + }, + { + "id": "model.draft.is_valid.file_ids.app_error", + "translation": "Invalid file IDs." + }, + { + "id": "model.draft.is_valid.create_at.app_error", + "translation": "Create at must be a valid time." + }, + { + "id": "model.draft.is_valid.channel_id.app_error", + "translation": "Invalid channel ID." + }, + { + "id": "model.acknowledgement.is_valid.user_id.app_error", + "translation": "Invalid user ID." + }, + { + "id": "model.acknowledgement.is_valid.post_id.app_error", + "translation": "Invalid post ID." + }, + { + "id": "app.post_prority.get_for_post.app_error", + "translation": "Unable to get post priority for post" + }, + { + "id": "app.draft.update.app_error", + "translation": "Unable to update the draft." + }, + { + "id": "app.draft.save.app_error", + "translation": "Unable to save the draft." + }, + { + "id": "app.draft.get_for_draft.app_error", + "translation": "Unable to get files for draft." + }, + { + "id": "app.draft.get_drafts.app_error", + "translation": "Unable to get user's drafts." + }, + { + "id": "app.draft.get.app_error", + "translation": "Unable to get the draft." + }, + { + "id": "app.draft.feature_disabled", + "translation": "Drafts feature is disabled." + }, + { + "id": "app.draft.delete.app_error", + "translation": "Unable to delete the draft." + }, + { + "id": "app.channel.get_priority_for_posts.app_error", + "translation": "Unable to get the priority for posts" + }, + { + "id": "app.acknowledgement.delete.app_error", + "translation": "Unable to delete acknowledgement." + }, + { + "id": "api.drafts.disabled.app_error", + "translation": "Drafts feature is disabled." + }, + { + "id": "api.acknowledgement.delete.archived_channel.app_error", + "translation": "You cannot remove an acknowledgment in an archived channel." + }, + { + "id": "worktemplate.product_teams.feature_release.description.playbook", + "translation": "Create transparent workflows across development teams to ensure your feature development process is seamless." + }, + { + "id": "worktemplate.product_teams.feature_release.description.integration", + "translation": "Increase productivity in your channel by integrating a Jira bot and GitHub bot. These will be downloaded for you." + }, + { + "id": "worktemplate.product_teams.feature_release.description.channel", + "translation": "Chat with your team in a Feature Release channel that connects easily with your boards, playbooks and app bots." + }, + { + "id": "worktemplate.product_teams.feature_release.description.board", + "translation": "Use the Meeting Agenda board template for recurring meetings like standup and the Project Tasks board to manage the progress of tasks along the way." + }, + { + "id": "worktemplate.category.product_teams", + "translation": "Product Teams" + }, + { + "id": "model.draft.is_valid.priority.app_error", + "translation": "Invalid priority" + }, + { + "id": "api.templates.cloud_welcome_email.yearly_plan_button", + "translation": "View your invoice" + }, + { + "id": "api.templates.cloud_upgrade_confirmation_yearly.subtitle", + "translation": "Your {{.WorkspaceName}} workspace has now been upgraded." + }, + { + "id": "api.templates.cloud_upgrade_confirmation_monthly.subtitle", + "translation": "Your {{.WorkspaceName}} workspace has now been upgraded. You'll be charged from {{.Date}}." + }, + { + "id": "api.user.get_users.validation.app_error", + "translation": "Error fetching roles during validation." } ] diff --git a/i18n/es.json b/i18n/es.json index b3682a2730..68dd0230be 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -9175,10 +9175,6 @@ "id": "app.insights.feature_disabled", "translation": "La característica Perspectivas está deshabilitada." }, - { - "id": "api.templates.cloud_upgrade_confirmation.subtitle", - "translation": " " - }, { "id": "api.insights.feature_disabled", "translation": "Las Perspectivas están detrás de una bandera de ajuste que no está habilitada." diff --git a/i18n/fr.json b/i18n/fr.json index 2600e826fa..107121d484 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -4573,7 +4573,7 @@ }, { "id": "oauth.gitlab.tos.error", - "translation": "Les conditions d'utilisation de GitLab ont été mises à jour. Veuillez vous rendre sur gitlab.com pour les accepter et réessayez de vous connecter à Mattermost." + "translation": "Les conditions d'utilisation de GitLab ont été mises à jour. Veuillez vous rendre sur {{.URL}} pour les accepter et réessayez de vous connecter à Mattermost." }, { "id": "plugin.api.update_user_status.bad_status", @@ -8803,10 +8803,6 @@ "id": "api.templates.cloud_upgrade_confirmation.title", "translation": "Vous avez été mis à niveau !" }, - { - "id": "api.templates.cloud_upgrade_confirmation.subtitle", - "translation": "Votre espace de travail {{.WorkspaceName}} a été mis à niveau. Vous serez facturé le {{.Date}}" - }, { "id": "api.templates.cloud_upgrade_confirmation.subject", "translation": "Confirmation de mise à niveau de Mattermost" diff --git a/i18n/hu.json b/i18n/hu.json index 1ccbb817e8..3ddcd9f7b7 100644 --- a/i18n/hu.json +++ b/i18n/hu.json @@ -9159,10 +9159,6 @@ "id": "app.channel.get_file_count.app_error", "translation": "A csatorna fájljainak számát nem lehet lekérdezni" }, - { - "id": "api.templates.cloud_upgrade_confirmation.subtitle", - "translation": "Az Ön {{.WorkspaceName}} munkaterülete mostantól a megemelt verziót használja. A számlázás {{.Date}} napon kezdődik" - }, { "id": "api.templates.cloud_upgrade_confirmation.title", "translation": "Ön mostantól a megemelt verziót használja!" diff --git a/i18n/it.json b/i18n/it.json index 1773114ab7..762c7d9bcc 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -8175,10 +8175,6 @@ "id": "app.insights.feature_disabled", "translation": " " }, - { - "id": "api.templates.cloud_upgrade_confirmation.subtitle", - "translation": " " - }, { "id": "api.templates.questions_footer.title", "translation": " " diff --git a/i18n/ja.json b/i18n/ja.json index cc82a4b220..8e854aa2e9 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -289,7 +289,7 @@ }, { "id": "api.channel.update_channel_member_roles.scheme_role.app_error", - "translation": "与えられた役割はスキームによって管理されているため、チャンネルメンバーへ直接適用することはできません。" + "translation": "与えられたロールはスキームによって管理されているため、チャンネルメンバーへ直接適用することはできません。" }, { "id": "api.channel.update_channel_scheme.license.error", @@ -301,7 +301,7 @@ }, { "id": "api.channel.update_team_member_roles.scheme_role.app_error", - "translation": "与えられた役割はスキームによって管理されているため、チームメンバーへ直接適用することはできません。" + "translation": "与えられたロールはスキームによって管理されているため、チームメンバーへ直接適用することはできません。" }, { "id": "api.command.admin_only.app_error", @@ -2657,19 +2657,19 @@ }, { "id": "app.import.validate_role_import_data.description_invalid.error", - "translation": "役割の説明が不正です。" + "translation": "ロールの説明が不正です。" }, { "id": "app.import.validate_role_import_data.display_name_invalid.error", - "translation": "役割の表示名が不正です。" + "translation": "ロールの表示名が不正です。" }, { "id": "app.import.validate_role_import_data.invalid_permission.error", - "translation": "権限もしくは役割が不正です。" + "translation": "ロールもしくは役割が不正です。" }, { "id": "app.import.validate_role_import_data.name_invalid.error", - "translation": "役割の名前が不正です。" + "translation": "ロールの名前が不正です。" }, { "id": "app.import.validate_scheme_import_data.description_invalid.error", @@ -2693,7 +2693,7 @@ }, { "id": "app.import.validate_scheme_import_data.wrong_roles_for_scope.error", - "translation": "このスコープのスキームに誤った役割が与えられました。" + "translation": "このスコープのスキームに誤ったロールが与えられました。" }, { "id": "app.import.validate_team_import_data.description_length.error", @@ -2753,7 +2753,7 @@ }, { "id": "app.import.validate_user_channels_import_data.invalid_roles.error", - "translation": "ユーザーのチャネルメンバーシップの役割が不正です。" + "translation": "ユーザーのチャネルメンバーシップのロールが不正です。" }, { "id": "app.import.validate_user_import_data.auth_data_and_password.error", @@ -2825,7 +2825,7 @@ }, { "id": "app.import.validate_user_import_data.roles_invalid.error", - "translation": "ユーザーの役割が正しくありません。" + "translation": "ユーザーのロールが正しくありません。" }, { "id": "app.import.validate_user_import_data.username_invalid.error", @@ -2837,7 +2837,7 @@ }, { "id": "app.import.validate_user_teams_import_data.invalid_roles.error", - "translation": "ユーザーのチームメンバーシップの役割が不正です。" + "translation": "ユーザーのチームメンバーシップのロールが不正です。" }, { "id": "app.import.validate_user_teams_import_data.team_name_missing.error", @@ -2933,7 +2933,7 @@ }, { "id": "app.role.check_roles_exist.role_not_found", - "translation": "指定された役割は存在しません" + "translation": "指定されたロールは存在しません" }, { "id": "app.save_config.app_error", @@ -4741,7 +4741,7 @@ }, { "id": "web.error.unsupported_browser.min_os_version.mac", - "translation": "macOS 10.14+" + "translation": "macOS 11+" }, { "id": "web.error.unsupported_browser.min_browser_version.safari", @@ -5709,7 +5709,7 @@ }, { "id": "api.channel.update_team_member_roles.changing_guest_role.app_error", - "translation": "不正なチームメンバ更新: 手動でゲストの役割を追加/削除することはできません。" + "translation": "不正なチームメンバ更新: 手動でゲストのロールを追加/削除することはできません。" }, { "id": "api.channel.update_channel_privacy.default_channel_error", @@ -5721,7 +5721,7 @@ }, { "id": "api.channel.update_channel_member_roles.changing_guest_role.app_error", - "translation": "不正なチャンネルメンバー更新: 手動でゲストの役割を追加/削除することはできません。" + "translation": "不正なチャンネルメンバー更新: 手動でゲストのロールを追加/削除することはできません。" }, { "id": "api.channel.update_channel.typechange.app_error", @@ -6437,27 +6437,27 @@ }, { "id": "app.role.save.invalid_role.app_error", - "translation": "役割が不正です。" + "translation": "ロールが不正です。" }, { "id": "app.role.save.insert.app_error", - "translation": "新しい役割を保存できませんでした。" + "translation": "新しいロールを保存できませんでした。" }, { "id": "app.role.permanent_delete_all.app_error", - "translation": "すべての役割を完全に削除できませんでした。" + "translation": "すべてのロールを完全に削除できませんでした。" }, { "id": "app.role.get_by_names.app_error", - "translation": "役割を取得できませんでした。" + "translation": "ロールを取得できませんでした。" }, { "id": "app.role.get_by_name.app_error", - "translation": "役割を取得できませんでした。" + "translation": "ロールを取得できませんでした。" }, { "id": "app.role.get.app_error", - "translation": "役割を取得できませんでした。" + "translation": "ロールを取得できませんでした。" }, { "id": "model.config.is_valid.directory.app_error", @@ -7181,7 +7181,7 @@ }, { "id": "api.server.warn_metric.number_of_channels_50.start_trial.notification_body", - "translation": "チャンネルはコミュニケーションの改善をサポートするものですが、Mattermost全体でチャンネルの作成や参加が多くなるにつれ、システムを整理されたものにし続けることが課題になってきます。高度な権限設定により、どのユーザー、もしくはどの役割の人が何のアクションを実行可能かを設定することができます。例えば、チャンネル設定やメンバーの管理や、@channel、@hereなどのタグによるグループへの発信、新たなウェブフックの作成などを制限できます。\n\n詳しくは[高度な権限設定の利用に関する説明](https://www.mattermost.com/docs-advanced-permissions/?utm_medium=product&utm_source=mattermost-advisor-bot&utm_content=advanced-permissions)を参照してください\n\nトライアル開始 をクリックすると、[Mattermost Software Evaluation Agreement](https://mattermost.com/software-evaluation-agreement/) と [プライバシーポリシー](https://mattermost.com/privacy-policy/)に同意したことになり、製品に関する電子メールを受信するようになります。" + "translation": "チャンネルはコミュニケーションの改善をサポートするものですが、Mattermost全体でチャンネルの作成や参加が多くなるにつれ、システムを整理されたものにし続けることが課題になってきます。高度な権限設定により、どのユーザー、もしくはどのロールの人が何のアクションを実行可能かを設定することができます。例えば、チャンネル設定やメンバーの管理や、@channel、@hereなどのタグによるグループへの発信、新たなウェブフックの作成などを制限できます。\n\n詳しくは[高度な権限設定の利用に関する説明](https://www.mattermost.com/docs-advanced-permissions/?utm_medium=product&utm_source=mattermost-advisor-bot&utm_content=advanced-permissions)を参照してください\n\nトライアル開始 をクリックすると、[Mattermost Software Evaluation Agreement](https://mattermost.com/software-evaluation-agreement/) と [プライバシーポリシー](https://mattermost.com/privacy-policy/)に同意したことになり、製品に関する電子メールを受信するようになります。" }, { "id": "api.server.warn_metric.number_of_channels_50.contact_us.email_body", @@ -7189,7 +7189,7 @@ }, { "id": "api.server.warn_metric.number_of_channels_50.notification_body", - "translation": "チャンネルはコミュニケーションの改善をサポートするものですが、Mattermost全体でチャンネルの作成や参加が多くなるにつれ、システムを整理されたものにし続けることが課題になってきます。高度な権限設定により、どのユーザー、もしくはどの役割の人が何のアクションを実行可能かを設定することができます。例えば、チャンネル設定やメンバーの管理や、@channel、@hereなどのタグによるグループへの発信、新たなウェブフックの作成などを制限できます。\n\n詳しくは[高度な権限設定の利用に関する説明](https://www.mattermost.com/docs-advanced-permissions/?utm_medium=product&utm_source=mattermost-advisor-bot&utm_content=advanced-permissions)を参照してください\n\n問い合わせ をクリックすると、あなたの情報が Mattermost, Inc. へ共有されます。詳しくは[説明文書](https://mattermost.com/pl/default-admin-advisory)を参照してください" + "translation": "チャンネルはコミュニケーションの改善をサポートするものですが、Mattermost全体でチャンネルの作成や参加が多くなるにつれ、システムを整理されたものにし続けることが課題になってきます。高度な権限設定により、どのユーザー、もしくはどのロールの人が何のアクションを実行可能かを設定することができます。例えば、チャンネル設定やメンバーの管理や、@channel、@hereなどのタグによるグループへの発信、新たなウェブフックの作成などを制限できます。\n\n詳しくは[高度な権限設定の利用に関する説明](https://www.mattermost.com/docs-advanced-permissions/?utm_medium=product&utm_source=mattermost-advisor-bot&utm_content=advanced-permissions)を参照してください\n\n問い合わせ をクリックすると、あなたの情報が Mattermost, Inc. へ共有されます。詳しくは[説明文書](https://mattermost.com/pl/default-admin-advisory)を参照してください" }, { "id": "api.server.warn_metric.number_of_channels_50.notification_title", @@ -8885,11 +8885,11 @@ }, { "id": "model.user.is_valid.roles_limit.app_error", - "translation": "{{.Limit}}文字以上の不正なユーザーの役割です。" + "translation": "{{.Limit}}文字以上の不正なユーザーのロールです。" }, { "id": "model.team_member.is_valid.roles_limit.app_error", - "translation": "{{.Limit}} 文字より長い不正なチームメンバーの役割です。" + "translation": "{{.Limit}} 文字より長い不正なチームメンバーのロールです。" }, { "id": "model.session.is_valid.user_id.app_error", @@ -8897,7 +8897,7 @@ }, { "id": "model.channel_member.is_valid.roles_limit.app_error", - "translation": "{{.Limit}} 文字より長い不正なチャンネルメンバーの役割です。" + "translation": "{{.Limit}} 文字より長い不正なチャンネルメンバーのロールです。" }, { "id": "model.session.is_valid.roles_limit.app_error", @@ -8937,7 +8937,7 @@ }, { "id": "app.role.get_all.app_error", - "translation": "全ての役割を取得できませんでした。" + "translation": "全てのロールを取得できませんでした。" }, { "id": "api.user.view_archived_channels.get_users_in_channel.app_error", @@ -9163,10 +9163,6 @@ "id": "api.templates.cloud_upgrade_confirmation.title", "translation": "アップグレードが完了しました!" }, - { - "id": "api.templates.cloud_upgrade_confirmation.subtitle", - "translation": "あなたの {{.WorkspaceName}} ワークスペースがアップグレードされました。{{.Date}}から課金されます" - }, { "id": "api.templates.cloud_upgrade_confirmation.subject", "translation": "Mattermostアップグレードの確認" @@ -9187,10 +9183,6 @@ "id": "api.file.cloud_upload.app_error", "translation": "クラウドインスタンスへのmmctlによるアップロードはサポートされていません。こちらのドキュメントを確認してください:https://docs.mattermost.com/manage/cloud-data-export.html。" }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "有効な統合機能数の上限 {{.NumIntegrations}} に達しました。無制限に統合機能をインストールするには、いずれかの有料プランにアップグレードしてください。" - }, { "id": "model.config.is_valid.image_decoder_concurrency.app_error", "translation": "デコーダーの並列数 {{.Value}} は不正です。正の数または-1であるべきです。" @@ -9313,7 +9305,7 @@ }, { "id": "api.templates.delinquency_75.subtitle1", - "translation": "これは、{{.DelinquencyDate}}以降、Mattermost Cloudワークスペースの支払いを受け取っていないことを知らせる最後のリマンダーです" + "translation": "これは、{{.DelinquencyDate}}以降、Mattermost Cloudワークスペースの支払いを受け取っていないことを知らせる最後のリマンダーです。" }, { "id": "api.templates.delinquency_75.subject", @@ -9333,7 +9325,7 @@ }, { "id": "api.templates.delinquency_7.subtitle1", - "translation": "直近のお支払いを処理できませんでした" + "translation": "直近のお支払いを処理できませんでした。" }, { "id": "api.templates.delinquency_60.title", @@ -9373,7 +9365,7 @@ }, { "id": "api.templates.delinquency_45.subtitle1", - "translation": "{{.DelinquencyDate}} 付の未払い請求書への支払いを行うことができませんでした。お客様のワークスペースはダウングレードされる可能性があります。" + "translation": "{{.DelinquencyDate}} 以降の未払い請求書への支払いを行うことができませんでした。お客様のワークスペースはダウングレードされる可能性があります。" }, { "id": "api.templates.delinquency_45.subject", @@ -9425,7 +9417,7 @@ }, { "id": "api.templates.delinquency_14.subject", - "translation": "Mattermost {{.Plan}} の支払いが遅れています。" + "translation": "Mattermost {{.Plan}} の支払いが遅れています" }, { "id": "api.command_marketplace.unsupported.app_error", @@ -9554,5 +9546,185 @@ { "id": "app.collection.add_collection.exists.app_error", "translation": "Collection typeはすでに存在しています。" + }, + { + "id": "model.draft.is_valid.user_id.app_error", + "translation": "不正なuser idです。" + }, + { + "id": "model.draft.is_valid.update_at.app_error", + "translation": "更新日時は有効な時刻でなくてはなりません。" + }, + { + "id": "model.draft.is_valid.root_id.app_error", + "translation": "不正なroot idです。" + }, + { + "id": "model.draft.is_valid.props.app_error", + "translation": "不正なpropsです。" + }, + { + "id": "model.draft.is_valid.msg.app_error", + "translation": "不正なmessageです。" + }, + { + "id": "model.draft.is_valid.file_ids.app_error", + "translation": "不正なfile idsです。" + }, + { + "id": "model.draft.is_valid.create_at.app_error", + "translation": "Create atは有効な時刻でなくてはなりません。" + }, + { + "id": "model.draft.is_valid.channel_id.app_error", + "translation": "不正なchannel idです。" + }, + { + "id": "model.acknowledgement.is_valid.user_id.app_error", + "translation": "不正なuser idです。" + }, + { + "id": "model.acknowledgement.is_valid.post_id.app_error", + "translation": "不正なpost idです。" + }, + { + "id": "app.post_prority.get_for_post.app_error", + "translation": "投稿に対する優先度を取得できませんでした" + }, + { + "id": "app.draft.update.app_error", + "translation": "下書きを更新できませんでした。" + }, + { + "id": "app.draft.save.app_error", + "translation": "下書きを保存できませんでした。" + }, + { + "id": "app.draft.get_for_draft.app_error", + "translation": "下書きに添付されたファイルを取得できませんでした。" + }, + { + "id": "app.draft.get_drafts.app_error", + "translation": "ユーザーの下書きを取得できませんでした。" + }, + { + "id": "app.draft.get.app_error", + "translation": "下書きを取得できませんでした。" + }, + { + "id": "app.draft.feature_disabled", + "translation": "下書き機能は無効化されています。" + }, + { + "id": "app.draft.delete.app_error", + "translation": "下書きを削除できませんでした。" + }, + { + "id": "app.channel.get_priority_for_posts.app_error", + "translation": "投稿の優先度を取得できませんでした" + }, + { + "id": "app.channel.count_urgent_posts_since.app_error", + "translation": "指定された日付以降の緊急の投稿をカウントできませんでした。" + }, + { + "id": "app.acknowledgement.save.save.app_error", + "translation": "投稿への確認応答を保存できませんでした。" + }, + { + "id": "app.acknowledgement.getforpost.get.app_error", + "translation": "投稿への確認応答を取得できませんでした。" + }, + { + "id": "app.acknowledgement.get.app_error", + "translation": "確認応答を取得できませんでした。" + }, + { + "id": "app.acknowledgement.delete.app_error", + "translation": "確認応答を削除できませんでした。" + }, + { + "id": "api.user.add_user_to_group_syncables.not_ldap_user.app_error", + "translation": "LDAPユーザーではありません" + }, + { + "id": "api.upload.create.upload_too_large.app_error", + "translation": "ファイルをアップロードできませんでした。ファイルが大きすぎます。" + }, + { + "id": "api.drafts.disabled.app_error", + "translation": "下書き機能は無効化されています。" + }, + { + "id": "api.draft.create_draft.can_not_draft_to_deleted.error", + "translation": "削除されたチャンネルの下書きは保存できません" + }, + { + "id": "api.admin.syncables_error", + "translation": "group-teams と group-channels にユーザーを追加できませんでした" + }, + { + "id": "api.acknowledgement.save.archived_channel.app_error", + "translation": "アーカイブされたチャンネルで確認応答をすることはできません。" + }, + { + "id": "api.acknowledgement.delete.deadline.app_error", + "translation": "5分以上経過した確認応答は削除できません。" + }, + { + "id": "api.acknowledgement.delete.archived_channel.app_error", + "translation": "アーカイブされたチャンネルでは、確認応答を削除することはできません。" + }, + { + "id": "worktemplate.product_teams.feature_release.description.playbook", + "translation": "開発チーム間で透明性の高いワークフローを作成し、機能開発プロセスをシームレスにすることができます。" + }, + { + "id": "worktemplate.product_teams.feature_release.description.integration", + "translation": "Jira BotやGitHub Botと統合し、生産性を高めましょう。これらはあなたのためにダウンロードされます。" + }, + { + "id": "worktemplate.product_teams.feature_release.description.channel", + "translation": "Boards、Playbooks、Botと簡単に接続できる Feature Release チャンネルでチームとチャットできます。" + }, + { + "id": "worktemplate.product_teams.feature_release.description.board", + "translation": "スタンドアップなどの定期的なミーティングには Meeting Agenda ボードテンプレート、タスクの進捗管理には Project Task ボードをご利用ください。" + }, + { + "id": "worktemplate.category.product_teams", + "translation": "製品チーム" + }, + { + "id": "model.draft.is_valid.priority.app_error", + "translation": "不正な優先度" + }, + { + "id": "app.worktemplates.get_templates.app_error", + "translation": "作業テンプレートを取得できませんでした" + }, + { + "id": "app.worktemplates.get_categories.app_error", + "translation": "作業テンプレートのカテゴリを取得できませんでした" + }, + { + "id": "api.user.get_users.validation.app_error", + "translation": "検証中のロール取得時にエラーが発生しました。" + }, + { + "id": "api.templates.cloud_welcome_email.yearly_plan_button", + "translation": "請求書を見る" + }, + { + "id": "api.templates.cloud_upgrade_confirmation_yearly.subtitle", + "translation": "ワークスペース {{.WorkspaceName}} がアップグレードされました。" + }, + { + "id": "api.templates.cloud_upgrade_confirmation_monthly.subtitle", + "translation": "ワークスペース {{.WorkspaceName}} がアップグレードされました。{{.Date}} から課金されます" + }, + { + "id": "api.server.hosted_signup_unavailable.error", + "translation": "セルフホスティングの利用登録では、ポータルは利用できません。" } ] diff --git a/i18n/nl.json b/i18n/nl.json index 2191b1f2bc..683aa0f875 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -9178,10 +9178,6 @@ "id": "api.templates.cloud_upgrade_confirmation.title", "translation": "Je bent nu geüpgraded!" }, - { - "id": "api.templates.cloud_upgrade_confirmation.subtitle", - "translation": "Jouw {{.WorkspaceName}} werkruimte is nu geüpgraded. Dit zal gefactureerd worden vanaf {{.Date}}" - }, { "id": "api.templates.cloud_upgrade_confirmation.subject", "translation": "Bevestiging Mattermost upgrade" diff --git a/i18n/pl.json b/i18n/pl.json index 1a7d81b48a..cc23eeb202 100644 --- a/i18n/pl.json +++ b/i18n/pl.json @@ -9171,10 +9171,6 @@ "id": "api.templates.cloud_upgrade_confirmation.title", "translation": "Zostałeś uaktualniony!" }, - { - "id": "api.templates.cloud_upgrade_confirmation.subtitle", - "translation": "Twoja {{.WorkspaceName}} została zaktualizowana. Opłaty będą naliczane od {{.Date}}" - }, { "id": "api.templates.cloud_upgrade_confirmation.subject", "translation": "Potwierdzenie Aktualizacji Mattermost" @@ -9313,7 +9309,7 @@ }, { "id": "api.templates.delinquency_14.subject", - "translation": "Zaległa płatność za Twój Mattermost {{.Plan}}." + "translation": "Zaległa płatność za Twój Mattermost {{.Plan}}" }, { "id": "api.templates.delinquency_14.button", @@ -9329,7 +9325,7 @@ }, { "id": "api.templates.delinquency_75.subtitle1", - "translation": "Jest to ostateczne przypomnienie, że nie otrzymaliśmy płatności za obszar roboczy Mattermost Cloud od {{.DelinquencyDate}}" + "translation": "Jest to ostateczne przypomnienie, że nie otrzymaliśmy płatności za obszar roboczy Mattermost Cloud od {{.DelinquencyDate}}." }, { "id": "api.templates.delinquency_75.subject", @@ -9353,7 +9349,7 @@ }, { "id": "api.templates.delinquency_7.subtitle1", - "translation": "Nie mogliśmy przetworzyć Twojej ostatniej płatności" + "translation": "Nie mogliśmy przetworzyć Twojej ostatniej płatności." }, { "id": "api.templates.delinquency_7.button", @@ -9401,7 +9397,7 @@ }, { "id": "api.templates.delinquency_45.subtitle1", - "translation": "Nie udało nam się zebrać płatności za zaległe faktury z datą {{.DelinquencyDate}}. Twój obszar roboczy jest zagrożony zdegradowaniem." + "translation": "Od {{.DelinquencyDate}} nie udało nam się zebrać płatności za zaległe faktury. Twój obszar roboczy jest zagrożony obniżeniem poziomu." }, { "id": "api.templates.delinquency_45.subject", @@ -9417,7 +9413,7 @@ }, { "id": "api.templates.delinquency_30.subtitle2", - "translation": "jeśli nie zostaną podjęte żadne działania, Twój obszar roboczy zostanie zdegradowany, a następujące dane mogą zostać zarchiwizowane:" + "translation": "Jeśli nie zostaną podjęte żadne działania, Twój obszar roboczy zostanie zdegradowany, a następujące dane mogą zostać zarchiwizowane:" }, { "id": "api.templates.delinquency_30.subtitle1", @@ -9570,5 +9566,173 @@ { "id": "api.upload.create.upload_too_large.app_error", "translation": "Nie można przesłać pliku. Plik jest zbyt duży." + }, + { + "id": "model.draft.is_valid.user_id.app_error", + "translation": "Nieprawidłowe id użytkownika." + }, + { + "id": "model.draft.is_valid.update_at.app_error", + "translation": "Aktualizacja w musi zawierać prawidłowy czas." + }, + { + "id": "model.draft.is_valid.root_id.app_error", + "translation": "Nieprawidłowy root id." + }, + { + "id": "model.draft.is_valid.props.app_error", + "translation": "Nieprawidłowa wartość." + }, + { + "id": "model.draft.is_valid.msg.app_error", + "translation": "Nieprawidłowa wiadomość." + }, + { + "id": "model.draft.is_valid.file_ids.app_error", + "translation": "Niepoprawne identyfikatory plików." + }, + { + "id": "model.draft.is_valid.create_at.app_error", + "translation": "Data utworzenia musi zawierać prawidłowy czas." + }, + { + "id": "model.draft.is_valid.channel_id.app_error", + "translation": "Nieprawidłowy identyfikator kanału." + }, + { + "id": "model.acknowledgement.is_valid.user_id.app_error", + "translation": "Nieprawidłowe id użytkownika." + }, + { + "id": "model.acknowledgement.is_valid.post_id.app_error", + "translation": "Nieprawidłowy identyfikator posta." + }, + { + "id": "app.post_prority.get_for_post.app_error", + "translation": "Nie można uzyskać priorytetu dla posta" + }, + { + "id": "app.draft.update.app_error", + "translation": "Nie można zaktualizować szkicu." + }, + { + "id": "app.draft.save.app_error", + "translation": "Nie można zapisać Szkicu." + }, + { + "id": "app.draft.get_for_draft.app_error", + "translation": "Nie można uzyskać plików dla Szkiców." + }, + { + "id": "app.draft.get_drafts.app_error", + "translation": "Nie można uzyskać szkiców użytkownika." + }, + { + "id": "app.draft.get.app_error", + "translation": "Nie mogę pobrać szablonu." + }, + { + "id": "app.draft.feature_disabled", + "translation": "Funkcja szkiców jest wyłączona." + }, + { + "id": "app.draft.delete.app_error", + "translation": "Nie można usunąć projektu." + }, + { + "id": "app.channel.get_priority_for_posts.app_error", + "translation": "Nie można uzyskać priorytetu dla postów" + }, + { + "id": "app.channel.count_urgent_posts_since.app_error", + "translation": "Nie można zliczyć pilnych postów od podanej daty." + }, + { + "id": "app.acknowledgement.save.save.app_error", + "translation": "Nie można zapisać potwierdzenia dla posta." + }, + { + "id": "app.acknowledgement.getforpost.get.app_error", + "translation": "Nie można uzyskać potwierdzenia dla posta." + }, + { + "id": "app.acknowledgement.get.app_error", + "translation": "Nie można uzyskać potwierdzenia." + }, + { + "id": "app.acknowledgement.delete.app_error", + "translation": "Nie można usunąć potwierdzenia." + }, + { + "id": "api.drafts.disabled.app_error", + "translation": "Funkcja szkiców jest wyłączona." + }, + { + "id": "api.draft.create_draft.can_not_draft_to_deleted.error", + "translation": "Nie można zapisać wersji roboczej do usuniętego kanału" + }, + { + "id": "api.acknowledgement.save.archived_channel.app_error", + "translation": "Nie można potwierdzić w zarchiwizowanym kanale." + }, + { + "id": "api.acknowledgement.delete.deadline.app_error", + "translation": "Nie można usunąć potwierdzenia po upływie 5 minut." + }, + { + "id": "api.acknowledgement.delete.archived_channel.app_error", + "translation": "Nie można usunąć potwierdzenia w zarchiwizowanym kanale." + }, + { + "id": "worktemplate.product_teams.feature_release.description.playbook", + "translation": "Twórz przejrzyste przepływy pracy pomiędzy zespołami programistów, aby zapewnić płynny proces rozwoju funkcji." + }, + { + "id": "worktemplate.product_teams.feature_release.description.integration", + "translation": "Zwiększ wydajność na swoim kanale, integrując bota Jira i bota Github. Zostaną one pobrane za Ciebie." + }, + { + "id": "worktemplate.product_teams.feature_release.description.channel", + "translation": "Czatuj ze swoim zespołem na kanale Feature Release, który łatwo łączy się z tablicami, playbookami i botami aplikacji." + }, + { + "id": "worktemplate.product_teams.feature_release.description.board", + "translation": "Użyj naszego szablonu tablicy Meeting Agenda do powtarzających się spotkań, takich jak standup, oraz naszej tablicy Project Tasks do zarządzania postępem zadań w trakcie." + }, + { + "id": "worktemplate.category.product_teams", + "translation": "Zespoły Produkcyjne" + }, + { + "id": "model.draft.is_valid.priority.app_error", + "translation": "Nieprawidłowy priorytet" + }, + { + "id": "app.worktemplates.get_templates.app_error", + "translation": "Nie można uzyskać szablonów roboczych" + }, + { + "id": "app.worktemplates.get_categories.app_error", + "translation": "Nie można uzyskać kategorii szablonów roboczych" + }, + { + "id": "api.user.get_users.validation.app_error", + "translation": "Błąd pobierania ról podczas sprawdzania poprawności." + }, + { + "id": "api.templates.cloud_welcome_email.yearly_plan_button", + "translation": "Zobacz swoją fakturę" + }, + { + "id": "api.templates.cloud_upgrade_confirmation_yearly.subtitle", + "translation": "Twoja przestrzeń robocza {{.WorkspaceName}} została zaktualizowana." + }, + { + "id": "api.templates.cloud_upgrade_confirmation_monthly.subtitle", + "translation": "Twoja {{.WorkspaceName}} została zaktualizowana. Opłaty będą naliczane od {{.Date}}" + }, + { + "id": "api.server.hosted_signup_unavailable.error", + "translation": "Portal niedostępny dla samodzielnej rejestracji." } ] diff --git a/i18n/ru.json b/i18n/ru.json index ce6fdce143..b31289ad9c 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -9171,10 +9171,6 @@ "id": "api.templates.cloud_upgrade_confirmation.title", "translation": "Обновление прошло успешно!" }, - { - "id": "api.templates.cloud_upgrade_confirmation.subtitle", - "translation": "Ваше рабочее пространство {{.WorkspaceName}} обновлено. Вам будет выставлен счет с {{.Date}}" - }, { "id": "api.templates.cloud_upgrade_confirmation.subject", "translation": "Подтверждение обновления Mattermost" @@ -9193,7 +9189,7 @@ }, { "id": "api.templates.delinquency_14.subject", - "translation": "Просрочена оплата вашего Mattermost {{.Plan}}." + "translation": "Просрочена оплата вашего Mattermost {{.Plan}}" }, { "id": "api.templates.delinquency_14.button", @@ -9343,10 +9339,6 @@ "id": "app.job.error", "translation": "Ошибка во время выполнения задания." }, - { - "id": "app.install_integration.reached_max_limit.error", - "translation": "Вы достигли лимита включенных интеграций - {{.NumIntegrations}} . Чтобы установить неограниченное количество интеграций, перейдите на один из наших платных тарифных планов." - }, { "id": "app.insights.feature_disabled", "translation": "Функция Insights отключена." @@ -9433,7 +9425,7 @@ }, { "id": "api.templates.delinquency_75.subtitle1", - "translation": "Это последнее напоминание о том, что мы не получили оплату за ваше рабочее пространство Mattermost Cloud с {{.DelinquencyDate}}" + "translation": "Это последнее напоминание о том, что мы не получили оплату за ваше рабочее пространство Mattermost Cloud с {{.DelinquencyDate}}." }, { "id": "api.templates.delinquency_75.subject", @@ -9453,7 +9445,7 @@ }, { "id": "api.templates.delinquency_7.subtitle1", - "translation": "Мы не смогли обработать ваш последний платёж" + "translation": "Мы не смогли обработать ваш последний платёж." }, { "id": "api.templates.delinquency_7.button", @@ -9513,7 +9505,7 @@ }, { "id": "api.templates.delinquency_30.subtitle2", - "translation": "если не предпринять никаких действий, ваше рабочее пространство будет понижено по тарифной сетке, а следующие данные могут быть заархивированы:" + "translation": "Если не предпринять никаких действий, ваше рабочее пространство будет понижено по тарифной сетке, а следующие данные могут быть заархивированы:" }, { "id": "api.templates.delinquency_30.subtitle1", @@ -9574,5 +9566,173 @@ { "id": "api.admin.syncables_error", "translation": "не удалось добавить пользователя в group-teams и group-channels" + }, + { + "id": "model.draft.is_valid.user_id.app_error", + "translation": "Некорректный user id." + }, + { + "id": "model.draft.is_valid.update_at.app_error", + "translation": "\"Обновить в\" должно быть корректным временем." + }, + { + "id": "model.draft.is_valid.root_id.app_error", + "translation": "Некорректный идентификатор root." + }, + { + "id": "model.draft.is_valid.props.app_error", + "translation": "Некорректные свойства." + }, + { + "id": "model.draft.is_valid.msg.app_error", + "translation": "Неверное сообщение." + }, + { + "id": "model.draft.is_valid.file_ids.app_error", + "translation": "Недопустимые идентификаторы файлов." + }, + { + "id": "model.draft.is_valid.create_at.app_error", + "translation": "\"Создать\" должно быть корректным временем." + }, + { + "id": "model.draft.is_valid.channel_id.app_error", + "translation": "Некорректный идентификатор канала." + }, + { + "id": "model.acknowledgement.is_valid.user_id.app_error", + "translation": "Некорректный user id." + }, + { + "id": "model.acknowledgement.is_valid.post_id.app_error", + "translation": "Неверный идентификатор сообщения." + }, + { + "id": "app.post_prority.get_for_post.app_error", + "translation": "Невозможно получить приоритет для сообщения" + }, + { + "id": "app.draft.update.app_error", + "translation": "Невозможно обновить черновик." + }, + { + "id": "app.draft.save.app_error", + "translation": "Невозможно сохранить черновик." + }, + { + "id": "app.draft.get_for_draft.app_error", + "translation": "Невозможно получить файлы для черновика." + }, + { + "id": "app.draft.get_drafts.app_error", + "translation": "Невозможно получить черновики пользователя." + }, + { + "id": "app.draft.get.app_error", + "translation": "Невозможно получить черновик." + }, + { + "id": "app.draft.feature_disabled", + "translation": "Функция \"Черновики\" отключена." + }, + { + "id": "app.draft.delete.app_error", + "translation": "Невозможно удалить черновик." + }, + { + "id": "app.channel.get_priority_for_posts.app_error", + "translation": "Невозможно получить приоритет для сообщений" + }, + { + "id": "app.channel.count_urgent_posts_since.app_error", + "translation": "Невозможно подсчитать срочные сообщения с указанной даты." + }, + { + "id": "app.acknowledgement.save.save.app_error", + "translation": "Невозможно сохранить подтверждение для сообщения." + }, + { + "id": "app.acknowledgement.getforpost.get.app_error", + "translation": "Невозможно получить подтверждение о получении сообщения." + }, + { + "id": "app.acknowledgement.get.app_error", + "translation": "Невозможно получить подтверждение." + }, + { + "id": "app.acknowledgement.delete.app_error", + "translation": "Невозможно удалить подтверждение." + }, + { + "id": "api.drafts.disabled.app_error", + "translation": "Функция \"Черновики\" отключена." + }, + { + "id": "api.draft.create_draft.can_not_draft_to_deleted.error", + "translation": "Невозможно сохранить черновик в удаленном канале" + }, + { + "id": "api.acknowledgement.save.archived_channel.app_error", + "translation": "Вы не можете подтвердить в архивированном канале." + }, + { + "id": "api.acknowledgement.delete.deadline.app_error", + "translation": "Вы не можете удалить подтверждение после того, как прошло 5 минут." + }, + { + "id": "api.acknowledgement.delete.archived_channel.app_error", + "translation": "Вы не можете удалить подтверждение в архивированном канале." + }, + { + "id": "worktemplate.product_teams.feature_release.description.playbook", + "translation": "Создайте прозрачные рабочие процессы между командами разработчиков, чтобы обеспечить бесперебойный процесс разработки функций." + }, + { + "id": "worktemplate.product_teams.feature_release.description.integration", + "translation": "Повысьте производительность вашего канала, интегрировав бота для Jira и бота для Github. Они будут загружены для вас." + }, + { + "id": "worktemplate.product_teams.feature_release.description.channel", + "translation": "Общайтесь со своей командой в канале Feature Release, который легко соединяется с вашими досками, сценариями и ботами приложений." + }, + { + "id": "worktemplate.product_teams.feature_release.description.board", + "translation": "Используйте наш шаблон доски \"Повестка дня совещания\" для повторяющихся совещаний, например, совещаний по подготовке к работе, и доску \"Задачи проекта\" для управления ходом выполнения задач." + }, + { + "id": "worktemplate.category.product_teams", + "translation": "Продуктовые команды" + }, + { + "id": "model.draft.is_valid.priority.app_error", + "translation": "Неверный приоритет" + }, + { + "id": "app.worktemplates.get_templates.app_error", + "translation": "Невозможно получить рабочие шаблоны" + }, + { + "id": "app.worktemplates.get_categories.app_error", + "translation": "Невозможно получить категории рабочих шаблонов" + }, + { + "id": "api.templates.cloud_welcome_email.yearly_plan_button", + "translation": "Просмотр счета-фактуры" + }, + { + "id": "api.templates.cloud_upgrade_confirmation_yearly.subtitle", + "translation": "Ваше рабочее пространство {{.WorkspaceName}} теперь обновлено." + }, + { + "id": "api.templates.cloud_upgrade_confirmation_monthly.subtitle", + "translation": "Ваше рабочее пространство {{.WorkspaceName}} обновлено. Вам будет выставлен счет с {{.Date}}" + }, + { + "id": "api.user.get_users.validation.app_error", + "translation": "Ошибка при получении ролей во время проверки." + }, + { + "id": "api.server.hosted_signup_unavailable.error", + "translation": "Портал недоступен для самостоятельной регистрации." } ] diff --git a/i18n/sv.json b/i18n/sv.json index 1a23d896c6..3d3d19a447 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -65,7 +65,7 @@ }, { "id": "web.error.unsupported_browser.min_os_version.mac", - "translation": "macOS 10.14+" + "translation": "macOS 11+" }, { "id": "web.error.unsupported_browser.min_browser_version.safari", @@ -9134,10 +9134,6 @@ "id": "api.templates.cloud_upgrade_confirmation.title", "translation": "Du är nu uppgraderad!" }, - { - "id": "api.templates.cloud_upgrade_confirmation.subtitle", - "translation": "Din {{.WorkspaceName}}-arbetsyta har nu uppgraderats. Du kommer att faktureras från och med {{.Date}}" - }, { "id": "api.templates.cloud_upgrade_confirmation.subject", "translation": "Bekräftelse av uppgradering av Mattermost" @@ -9304,7 +9300,7 @@ }, { "id": "api.templates.delinquency_45.subtitle1", - "translation": "Vi har inte kunnat få betalt för utestående fakturor med datum {{.DelinquencyDate}}. Din arbetsyta riskerar att nedgraderas." + "translation": "Vi har inte kunnat få betalt för utestående fakturor sedan {{.DelinquencyDate}}. Din arbetsyta riskerar att nedgraderas." }, { "id": "api.templates.delinquency_45.subject", @@ -9320,7 +9316,7 @@ }, { "id": "api.templates.delinquency_30.subtitle2", - "translation": "om ingen åtgärd vidtas kommer din arbetsyta att nedgraderas och följande uppgifter kan komma att arkiveras:" + "translation": "Om ingen åtgärd vidtas kommer din arbetsyta att nedgraderas och följande uppgifter kan komma att arkiveras:" }, { "id": "api.templates.delinquency_30.subtitle1", @@ -9356,7 +9352,7 @@ }, { "id": "api.templates.delinquency_14.subject", - "translation": "Betalningen för din Mattermost {{.Plan}} är försenad." + "translation": "Betalningen för din Mattermost {{.Plan}} är försenad" }, { "id": "ent.saml.configure.certificate_parse_error.app_error", @@ -9448,7 +9444,7 @@ }, { "id": "api.templates.delinquency_75.subtitle1", - "translation": "Detta är en sista påminnelse. Vi har inte mottagit betalning för din Mattermost Cloud-arbetsyta sedan {{.DelinquencyDate}}" + "translation": "Detta är en sista påminnelse. Vi har inte mottagit betalning för din Mattermost Cloud-arbetsyta sedan {{.DelinquencyDate}}." }, { "id": "api.templates.delinquency_75.subject", @@ -9472,7 +9468,7 @@ }, { "id": "api.templates.delinquency_7.subtitle1", - "translation": "Vi kunde inte behandla din senaste betalning" + "translation": "Vi kunde inte behandla din senaste betalning." }, { "id": "api.templates.delinquency_7.button", @@ -9557,5 +9553,181 @@ { "id": "app.collection.add_topic.exists.app_error", "translation": "Ämnestypen finns redan." + }, + { + "id": "app.draft.delete.app_error", + "translation": "Kunde inte att radera utkastet." + }, + { + "id": "app.acknowledgement.getforpost.get.app_error", + "translation": "Det går inte att få en bekräftelse för publiceringen." + }, + { + "id": "app.acknowledgement.get.app_error", + "translation": "Det går inte att få en bekräftelse." + }, + { + "id": "app.acknowledgement.delete.app_error", + "translation": "Det går inte att radera bekräftelsen." + }, + { + "id": "api.user.add_user_to_group_syncables.not_ldap_user.app_error", + "translation": "inte en ldap-användare" + }, + { + "id": "api.upload.create.upload_too_large.app_error", + "translation": "Kunde inte ladda upp fil. Filen är för stor." + }, + { + "id": "api.drafts.disabled.app_error", + "translation": "Funktionen Utkast är inaktiverad." + }, + { + "id": "api.draft.create_draft.can_not_draft_to_deleted.error", + "translation": "Kan inte spara utkast i en borttagen kanal" + }, + { + "id": "api.admin.syncables_error", + "translation": "misslyckades med att lägga till användaren i gruppens team och gruppens kanaler" + }, + { + "id": "api.acknowledgement.save.archived_channel.app_error", + "translation": "Du kan inte bekräfta i en arkiverad kanal." + }, + { + "id": "api.acknowledgement.delete.deadline.app_error", + "translation": "Du kan inte radera en bekräftelse efter att 5 minuter har gått." + }, + { + "id": "api.acknowledgement.delete.archived_channel.app_error", + "translation": "Du kan inte ta bort en bekräftelse i en arkiverad kanal." + }, + { + "id": "worktemplate.product_teams.feature_release.description.playbook", + "translation": "Skapa transparenta arbetsflöden mellan utvecklingsteamen för att säkerställa att utvecklingsprocessen för funktioner är smidig." + }, + { + "id": "worktemplate.product_teams.feature_release.description.integration", + "translation": "Öka produktiviteten i kanalen genom att integrera en Jira-bot och en Github-bot. Dessa kommer att laddas ner åt dig." + }, + { + "id": "worktemplate.product_teams.feature_release.description.channel", + "translation": "Chatta med ditt team i kanalen för Kommande leveranser som enkelt kan anslutas till dina boards, playbooks och app-bottar." + }, + { + "id": "worktemplate.product_teams.feature_release.description.board", + "translation": "Använd vår mall för mötesagenda för återkommande möten exempelvis standup och vår Projekt-board för projektuppgifter och att hantera uppgifternas framskridande." + }, + { + "id": "worktemplate.category.product_teams", + "translation": "Produkt-team" + }, + { + "id": "model.draft.is_valid.user_id.app_error", + "translation": "Ogiltigt användarid." + }, + { + "id": "model.draft.is_valid.update_at.app_error", + "translation": "\"Update at\" måste vara en giltig tid." + }, + { + "id": "model.draft.is_valid.root_id.app_error", + "translation": "Ogiltigt root-id." + }, + { + "id": "model.draft.is_valid.priority.app_error", + "translation": "Ogiltig prioritet" + }, + { + "id": "model.draft.is_valid.msg.app_error", + "translation": "Ogiltigt meddelande." + }, + { + "id": "model.draft.is_valid.file_ids.app_error", + "translation": "Ogiltiga fil-ID:n." + }, + { + "id": "model.draft.is_valid.create_at.app_error", + "translation": "Skapad vid måste vara en giltig tid." + }, + { + "id": "model.draft.is_valid.channel_id.app_error", + "translation": "Ogiltigt kanal-id." + }, + { + "id": "model.acknowledgement.is_valid.user_id.app_error", + "translation": "Ogiltigt användarid." + }, + { + "id": "model.acknowledgement.is_valid.post_id.app_error", + "translation": "Ogiltigt meddelande-id." + }, + { + "id": "app.worktemplates.get_templates.app_error", + "translation": "Det går inte att få fram arbetsmallar" + }, + { + "id": "app.worktemplates.get_categories.app_error", + "translation": "Det går inte att få fram kategorier för arbetsmallar" + }, + { + "id": "app.post_prority.get_for_post.app_error", + "translation": "Det går inte att få fram inläggets prioritet" + }, + { + "id": "app.draft.update.app_error", + "translation": "Kunde inte uppdatera utkastet." + }, + { + "id": "app.draft.save.app_error", + "translation": "Kunde inte spara utkastet." + }, + { + "id": "app.draft.get_for_draft.app_error", + "translation": "Det går inte att hämta utkastets filer." + }, + { + "id": "app.draft.get_drafts.app_error", + "translation": "Kunde inte hämta användarens utkast." + }, + { + "id": "app.draft.get.app_error", + "translation": "Kunde inte hämta utkastet." + }, + { + "id": "app.draft.feature_disabled", + "translation": "Funktionen utkast är inaktiverad." + }, + { + "id": "app.channel.get_priority_for_posts.app_error", + "translation": "Det går inte att få fram inläggets prioritet" + }, + { + "id": "app.channel.count_urgent_posts_since.app_error", + "translation": "Kunde inte räkna brådskande meddelanden från angivet datum." + }, + { + "id": "app.acknowledgement.save.save.app_error", + "translation": "Det går inte att spara bekräftelsen för inlägget." + }, + { + "id": "api.user.get_users.validation.app_error", + "translation": "Fel vid hämtning av roller vid valideringen." + }, + { + "id": "api.templates.cloud_welcome_email.yearly_plan_button", + "translation": "Visa fakturan" + }, + { + "id": "api.templates.cloud_upgrade_confirmation_yearly.subtitle", + "translation": "Din arbetsyta {{.WorkspaceName}} har nu uppgraderats." + }, + { + "id": "api.templates.cloud_upgrade_confirmation_monthly.subtitle", + "translation": "Din arbetsyta {{.WorkspaceName}} har nu uppgraderats. Du kommer att faktureras från och med {{.Date}}" + }, + { + "id": "model.draft.is_valid.props.app_error", + "translation": "Ogiltiga attribut." } ] diff --git a/i18n/tr.json b/i18n/tr.json index 82331240fc..39a1074bc0 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -5857,7 +5857,7 @@ }, { "id": "web.error.unsupported_browser.min_os_version.mac", - "translation": "macOS 10.14+" + "translation": "macOS 11+" }, { "id": "web.error.unsupported_browser.min_os_version.windows", @@ -6733,7 +6733,7 @@ }, { "id": "app.channel.count_posts_since.app_error", - "translation": "Belirtilen tarihten sonraki ileti sayıları hesaplanamadı." + "translation": "Belirtilen tarihten sonraki ileti sayıları belirlenemedi." }, { "id": "app.channel.analytics_type_count.app_error", @@ -9170,10 +9170,6 @@ "id": "api.templates.cloud_upgrade_confirmation.title", "translation": "Üst tarifeye geçtiniz!" }, - { - "id": "api.templates.cloud_upgrade_confirmation.subtitle", - "translation": "{{.WorkspaceName}} çalışma alanınız üst tarifeye geçirildi. Faturanız {{.Date}} tarihinden başlayarak hesaplanacak" - }, { "id": "api.templates.cloud_upgrade_confirmation.subject", "translation": "Mattermost üst tarifeye geçme onayı" @@ -9336,7 +9332,7 @@ }, { "id": "api.templates.delinquency_75.subtitle1", - "translation": "Bu son uyarıdır. Mattermost Cloud çalışma alanınızın {{.DelinquencyDate}} tarihindeki ödemesini alamadık" + "translation": "Bu son uyarıdır. Mattermost Cloud çalışma alanınızın ödemesini {{.DelinquencyDate}} tarihinden beri alamadık." }, { "id": "api.templates.delinquency_7.subtitle2", @@ -9360,7 +9356,7 @@ }, { "id": "api.templates.delinquency_7.subtitle1", - "translation": "Son ödemenizi alamadık" + "translation": "Son ödemenizi alamadık." }, { "id": "api.templates.delinquency_7.button", @@ -9388,7 +9384,7 @@ }, { "id": "api.templates.delinquency_45.subtitle1", - "translation": "{{.DelinquencyDate}} tarihli ödenmemiş faturaların ödemesini alamadık. Çalışma alanınızın alt tarifeye geçirilme riski var." + "translation": "{{.DelinquencyDate}} tarihinden beri ödenmemiş faturaların ödemesini alamadık. Çalışma alanınızın alt tarifeye geçirilme riski var." }, { "id": "api.templates.delinquency_45.subject", @@ -9476,7 +9472,7 @@ }, { "id": "api.templates.delinquency_14.subject", - "translation": "Mattermost {{.Plan}} tarifenizin ödeme süresi geçmiş." + "translation": "Mattermost {{.Plan}} tarifenizin ödeme süresi geçmiş" }, { "id": "api.templates.delinquency_14.button", @@ -9557,5 +9553,181 @@ { "id": "app.collection.add_collection.exists.app_error", "translation": "Derleme türü zaten var." + }, + { + "id": "api.drafts.disabled.app_error", + "translation": "Taslaklar özelliği devre dışı." + }, + { + "id": "api.draft.create_draft.can_not_draft_to_deleted.error", + "translation": "Taslak silinmiş kanala kaydedilemedi" + }, + { + "id": "api.admin.syncables_error", + "translation": "kullanıcı group-teams ve group-channels üzerine eklenemedi" + }, + { + "id": "api.acknowledgement.save.archived_channel.app_error", + "translation": "Arşivlenmiş bir kanalda onay veremezsiniz." + }, + { + "id": "api.acknowledgement.delete.deadline.app_error", + "translation": "Bir onayı, verilmesinden 5 dakika geçtikten sonra kaldıramazsınız." + }, + { + "id": "api.acknowledgement.delete.archived_channel.app_error", + "translation": "Arşivlenmiş bir kanaldaki bir onayı kaldıramazsınız." + }, + { + "id": "worktemplate.product_teams.feature_release.description.playbook", + "translation": "Özellik geliştirme sürecinizin sorunsuz olmasını sağlamak için geliştirme ekipleri arasında şeffaf iş akışları oluşturun." + }, + { + "id": "worktemplate.product_teams.feature_release.description.integration", + "translation": "Bir Jira botu ve Github botu ile bütünleştirerek kanalınızdaki üretkenliği artırın. Bu botlar sizin için indirilir." + }, + { + "id": "worktemplate.product_teams.feature_release.description.channel", + "translation": "Panolarınıza, senaryolarınıza ve uygulama botlarınıza kolayca bağlanan bir özellik yayını kanalında ekibinizle sohbet edin." + }, + { + "id": "worktemplate.product_teams.feature_release.description.board", + "translation": "Ayaküstü gibi yinelenen toplantılar için toplantı gündemi panosu kalıbımızı ve yol boyunca görevlerin ilerleyişini yönetmek için proje görevleri panomuzu kullanın." + }, + { + "id": "worktemplate.category.product_teams", + "translation": "Ürün takımları" + }, + { + "id": "model.draft.is_valid.user_id.app_error", + "translation": "Kullanıcı kodu geçersiz." + }, + { + "id": "model.draft.is_valid.update_at.app_error", + "translation": "Güncelleme zamanı geçerli bir zaman olmalıdır." + }, + { + "id": "model.draft.is_valid.root_id.app_error", + "translation": "Kök kodu geçersiz." + }, + { + "id": "model.draft.is_valid.props.app_error", + "translation": "Özellikler geçersiz." + }, + { + "id": "model.draft.is_valid.priority.app_error", + "translation": "Öncelik geçersiz" + }, + { + "id": "model.draft.is_valid.msg.app_error", + "translation": "İleti geçersiz." + }, + { + "id": "model.draft.is_valid.file_ids.app_error", + "translation": "Dosya kodları geçersiz." + }, + { + "id": "model.draft.is_valid.create_at.app_error", + "translation": "Oluşturulma zamanı geçerli bir zaman olmalıdır." + }, + { + "id": "model.draft.is_valid.channel_id.app_error", + "translation": "Kanal kodu geçersiz." + }, + { + "id": "model.acknowledgement.is_valid.user_id.app_error", + "translation": "Kullanıcı kodu geçersiz." + }, + { + "id": "model.acknowledgement.is_valid.post_id.app_error", + "translation": "İleti kodu geçersiz." + }, + { + "id": "app.worktemplates.get_templates.app_error", + "translation": "Çalışma kalıpları alınamadı" + }, + { + "id": "app.worktemplates.get_categories.app_error", + "translation": "Çalışma kalıbı kategorileri alınamadı" + }, + { + "id": "app.post_prority.get_for_post.app_error", + "translation": "İletinin önceliği alınamadı" + }, + { + "id": "app.draft.update.app_error", + "translation": "Taslak güncellenemedi." + }, + { + "id": "app.draft.get_drafts.app_error", + "translation": "Kullanıcının taslakları alınamadı." + }, + { + "id": "app.draft.save.app_error", + "translation": "Taslak kaydedilemedi." + }, + { + "id": "app.draft.get_for_draft.app_error", + "translation": "Taslağın dosyaları alınamadı." + }, + { + "id": "app.draft.get.app_error", + "translation": "Taslak alınamadı." + }, + { + "id": "app.draft.feature_disabled", + "translation": "Taslaklar özelliği devre dışı." + }, + { + "id": "app.draft.delete.app_error", + "translation": "Taslak silinemedi." + }, + { + "id": "app.channel.get_priority_for_posts.app_error", + "translation": "İletilerin önceliği alınamadı" + }, + { + "id": "app.channel.count_urgent_posts_since.app_error", + "translation": "Belirtilen tarihten sonraki acil iletilerin sayısı belirlenemedi." + }, + { + "id": "app.acknowledgement.save.save.app_error", + "translation": "İletinin onayı kaydedilemedi." + }, + { + "id": "app.acknowledgement.getforpost.get.app_error", + "translation": "İletinin onayı alınamadı." + }, + { + "id": "app.acknowledgement.get.app_error", + "translation": "Onay alınamadı." + }, + { + "id": "app.acknowledgement.delete.app_error", + "translation": "Onay silinemedi." + }, + { + "id": "api.user.add_user_to_group_syncables.not_ldap_user.app_error", + "translation": "bir LDAP kullanıcısı değil" + }, + { + "id": "api.upload.create.upload_too_large.app_error", + "translation": "Dosya yüklenemedi. Dosya çok büyük." + }, + { + "id": "api.user.get_users.validation.app_error", + "translation": "Doğrulama sırasında roller alınırken sorun çıktı." + }, + { + "id": "api.templates.cloud_welcome_email.yearly_plan_button", + "translation": "Faturanızı görüntüleyin" + }, + { + "id": "api.templates.cloud_upgrade_confirmation_yearly.subtitle", + "translation": "{{.WorkspaceName}} çalışma alanınız yükseltildi." + }, + { + "id": "api.templates.cloud_upgrade_confirmation_monthly.subtitle", + "translation": "{{.WorkspaceName}} çalışma alanınız güncellendi. Faturanız {{.Date}} tarihinden başlayarak hesaplanacak" } ] diff --git a/i18n/zh-CN.json b/i18n/zh-CN.json index a04ead8fd9..2d91449bce 100644 --- a/i18n/zh-CN.json +++ b/i18n/zh-CN.json @@ -9059,10 +9059,6 @@ "id": "api.templates.cloud_upgrade_confirmation.title", "translation": "您已经完成升级更新!" }, - { - "id": "api.templates.cloud_upgrade_confirmation.subtitle", - "translation": "您的“ {{.WorkspaceName}}”工作空间现在已经升级。您将从{{.TrialEnd}}开始收到账单通知" - }, { "id": "api.templates.cloud_upgrade_confirmation.subject", "translation": "确认升级Mattermost" diff --git a/jobs/export_process/worker.go b/jobs/export_process/worker.go index a9deaa8634..ddf9462b14 100644 --- a/jobs/export_process/worker.go +++ b/jobs/export_process/worker.go @@ -4,6 +4,7 @@ package export_process import ( + "context" "io" "path/filepath" @@ -19,6 +20,7 @@ const jobName = "ExportProcess" type AppIface interface { configservice.ConfigService WriteFile(fr io.Reader, path string) (int64, *model.AppError) + WriteFileContext(ctx context.Context, fr io.Reader, path string) (int64, *model.AppError) BulkExport(ctx request.CTX, writer io.Writer, outPath string, opts model.BulkExportOpts) *model.AppError Log() *mlog.Logger } @@ -42,25 +44,24 @@ func MakeWorker(jobServer *jobs.JobServer, app AppIface) model.Worker { rd, wr := io.Pipe() - errCh := make(chan *model.AppError, 1) go func() { - defer close(errCh) - _, appErr := app.WriteFile(rd, filepath.Join(outPath, exportFilename)) - errCh <- appErr + _, appErr := app.WriteFileContext(context.Background(), rd, filepath.Join(outPath, exportFilename)) + if appErr != nil { + // we close the reader here to prevent a deadlock when the bulk exporter tries to + // write into the pipe while app.WriteFile has already returned. The error will be + // returned by the writer part of the pipe when app.BulkExport tries to call + // wr.Write() on it. + rd.CloseWithError(appErr) // CloseWithError never returns an error + } }() appErr := app.BulkExport(request.EmptyContext(app.Log()), wr, outPath, opts) - if err := wr.Close(); err != nil { - mlog.Warn("Worker: error closing writer") - } + wr.Close() // Close never returns an error if appErr != nil { return appErr } - if appErr := <-errCh; appErr != nil { - return appErr - } return nil } worker := jobs.NewSimpleWorker(jobName, jobServer, execute, isEnabled) diff --git a/model/client4.go b/model/client4.go index 5347048f17..48d63e959c 100644 --- a/model/client4.go +++ b/model/client4.go @@ -1060,6 +1060,24 @@ func (c *Client4) GetUsers(page int, perPage int, etag string) ([]*User, *Respon return list, BuildResponse(r), nil } +// GetUsersWithChannelRoles returns a page of users on the system. Page counting starts at 0. +func (c *Client4) GetUsersWithCustomQueryParameters(page int, perPage int, queryParameters, etag string) ([]*User, *Response, error) { + query := fmt.Sprintf("?page=%v&per_page=%v&%v", page, perPage, queryParameters) + r, err := c.DoAPIGet(c.usersRoute()+query, etag) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var list []*User + if r.StatusCode == http.StatusNotModified { + return list, BuildResponse(r), nil + } + if err := json.NewDecoder(r.Body).Decode(&list); err != nil { + return nil, nil, NewAppError("GetUsers", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return list, BuildResponse(r), nil +} + // GetUsersInTeam returns a page of users on a team. Page counting starts at 0. func (c *Client4) GetUsersInTeam(teamId string, page int, perPage int, etag string) ([]*User, *Response, error) { query := fmt.Sprintf("?in_team=%v&page=%v&per_page=%v", teamId, page, perPage) @@ -1240,6 +1258,24 @@ func (c *Client4) GetUsersInGroup(groupID string, page int, perPage int, etag st return list, BuildResponse(r), nil } +// GetUsersInGroup returns a page of users in a group. Page counting starts at 0. +func (c *Client4) GetUsersInGroupByDisplayName(groupID string, page int, perPage int, etag string) ([]*User, *Response, error) { + query := fmt.Sprintf("?sort=display_name&in_group=%v&page=%v&per_page=%v", groupID, page, perPage) + r, err := c.DoAPIGet(c.usersRoute()+query, etag) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var list []*User + if r.StatusCode == http.StatusNotModified { + return list, BuildResponse(r), nil + } + if err := json.NewDecoder(r.Body).Decode(&list); err != nil { + return nil, nil, NewAppError("GetUsersInGroupByDisplayName", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return list, BuildResponse(r), nil +} + // GetUsersByIds returns a list of users based on the provided user ids. func (c *Client4) GetUsersByIds(userIds []string) ([]*User, *Response, error) { r, err := c.DoAPIPost(c.usersRoute()+"/ids", ArrayToJSON(userIds)) @@ -8540,6 +8576,72 @@ func (c *Client4) GetNewTeamMembersSince(teamID string, timeRange string, page i return newTeamMembersList, BuildResponse(r), nil } +func (c *Client4) SelfHostedSignupAvailable() (*Response, error) { + r, err := c.DoAPIGet(c.hostedCustomerRoute()+"/signup_available", "") + + if err != nil { + return BuildResponse(r), err + } + defer closeBody(r) + + return BuildResponse(r), nil +} + +func (c *Client4) SelfHostedSignupCustomer(form *SelfHostedCustomerForm) (*Response, *SelfHostedSignupCustomerResponse, error) { + payloadBytes, err := json.Marshal(form) + if err != nil { + return nil, nil, NewAppError("SelfHostedSignupCustomer", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + r, err := c.DoAPIPost(c.hostedCustomerRoute()+"/customer", string(payloadBytes)) + + if err != nil { + return BuildResponse(r), nil, err + } + data, err := io.ReadAll(r.Body) + if err != nil { + return BuildResponse(r), nil, err + } + defer closeBody(r) + + response := SelfHostedSignupCustomerResponse{} + err = json.Unmarshal(data, &response) + if err != nil { + return BuildResponse(r), nil, err + } + + return BuildResponse(r), &response, nil +} + +func (c *Client4) SelfHostedSignupConfirm(form *SelfHostedConfirmPaymentMethodRequest) (*Response, *SelfHostedSignupConfirmClientResponse, error) { + payloadBytes, err := json.Marshal(form) + if err != nil { + return nil, nil, NewAppError("SelfHostedSignupConfirm", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + r, err := c.DoAPIPost(c.hostedCustomerRoute()+"/confirm", string(payloadBytes)) + + if err != nil { + return BuildResponse(r), nil, err + } + + data, err := io.ReadAll(r.Body) + if err != nil { + return BuildResponse(r), nil, err + } + defer closeBody(r) + + response := SelfHostedSignupConfirmClientResponse{} + err = json.Unmarshal(data, &response) + if err != nil { + return BuildResponse(r), nil, err + } + + defer closeBody(r) + + return BuildResponse(r), &response, nil +} + func (c *Client4) GetPostInfo(postId string) (*PostInfo, *Response, error) { r, err := c.DoAPIGet(c.postRoute(postId)+"/info", "") if err != nil { diff --git a/model/cloud.go b/model/cloud.go index 6e66015a87..7feae13e3d 100644 --- a/model/cloud.go +++ b/model/cloud.go @@ -166,7 +166,6 @@ type Subscription struct { Seats int `json:"seats"` Status string `json:"status"` DNS string `json:"dns"` - IsPaidTier string `json:"is_paid_tier"` LastInvoice *Invoice `json:"last_invoice"` UpcomingInvoice *Invoice `json:"upcoming_invoice"` IsFreeTrial string `json:"is_free_trial"` @@ -291,17 +290,14 @@ type ProductLimits struct { Teams *TeamsLimits `json:"teams,omitempty"` } -type BootstrapSelfHostedSignupRequest struct { - Email string `json:"email"` -} - -type BootstrapSelfHostedSignupResponse struct { - Progress string `json:"progress"` -} - -type BootstrapSelfHostedSignupResponseInternal struct { - Progress string `json:"progress"` - License string `json:"license"` +// CreateSubscriptionRequest is the parameters for the API request to create a subscription. +type CreateSubscriptionRequest struct { + ProductID string `json:"product_id"` + AddOns []string `json:"add_ons"` + Seats int `json:"seats"` + Total float64 `json:"total"` + InternalPurchaseOrder string `json:"internal_purchase_order"` + DiscountID string `json:"discount_id"` } func (p *Product) IsYearly() bool { diff --git a/model/config.go b/model/config.go index 7b9913e976..7f9c807562 100644 --- a/model/config.go +++ b/model/config.go @@ -383,7 +383,7 @@ type ServiceSettings struct { CollapsedThreads *string `access:"experimental_features"` ManagedResourcePaths *string `access:"environment_web_server,write_restrictable,cloud_restrictable"` EnableCustomGroups *bool `access:"site_users_and_teams"` - SelfHostedFirstTimePurchase *bool `access:"write_restrictable,cloud_restrictable"` + SelfHostedPurchase *bool `access:"write_restrictable,cloud_restrictable"` AllowSyncedDrafts *bool `access:"site_posts"` } @@ -854,8 +854,8 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { s.AllowSyncedDrafts = NewBool(true) } - if s.SelfHostedFirstTimePurchase == nil { - s.SelfHostedFirstTimePurchase = NewBool(false) + if s.SelfHostedPurchase == nil { + s.SelfHostedPurchase = NewBool(true) } } diff --git a/model/feature_flags.go b/model/feature_flags.go index 9356df0b89..27bab4830e 100644 --- a/model/feature_flags.go +++ b/model/feature_flags.go @@ -104,7 +104,7 @@ func (f *FeatureFlags) SetDefaults() { f.AnnualSubscription = false f.ReduceOnBoardingTaskList = false f.ThreadsEverywhere = false - f.GlobalDrafts = false + f.GlobalDrafts = true } func (f *FeatureFlags) Plugins() map[string]string { diff --git a/model/group_syncable.go b/model/group_syncable.go index afad357a10..e876a4c55f 100644 --- a/model/group_syncable.go +++ b/model/group_syncable.go @@ -144,9 +144,7 @@ func (syncable *GroupSyncable) MarshalJSON() ([]byte, error) { Alias: (*Alias)(syncable), }) default: - return nil, &json.MarshalerError{ - Err: fmt.Errorf("unknown syncable type: %s", syncable.Type), - } + return nil, fmt.Errorf("unknown syncable type: %s", syncable.Type) } } diff --git a/model/group_syncable_test.go b/model/group_syncable_test.go new file mode 100644 index 0000000000..525ddf02a4 --- /dev/null +++ b/model/group_syncable_test.go @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGroupSyncableMarshal(t *testing.T) { + require.NotPanics(t, func() { + var syncable GroupSyncable + _, err := json.Marshal(&syncable) + require.Error(t, err) + t.Log(err.Error()) + }, "marshaling groupsyncable should not panic") +} diff --git a/model/hosted_customer.go b/model/hosted_customer.go new file mode 100644 index 0000000000..0e1373a2bc --- /dev/null +++ b/model/hosted_customer.go @@ -0,0 +1,58 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +type BootstrapSelfHostedSignupRequest struct { + Email string `json:"email"` + Reset bool `json:"reset"` +} + +type BootstrapSelfHostedSignupResponse struct { + Progress string `json:"progress"` +} + +type BootstrapSelfHostedSignupResponseInternal struct { + Progress string `json:"progress"` + License string `json:"license"` +} + +// email contained in token, so not in the request body. +type SelfHostedCustomerForm struct { + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + BillingAddress *Address `json:"billing_address"` + Organization string `json:"organization"` +} + +type SelfHostedConfirmPaymentMethodRequest struct { + StripeSetupIntentID string `json:"stripe_setup_intent_id"` + Subscription CreateSubscriptionRequest `json:"subscription"` +} + +// SelfHostedSignupPaymentResponse contains feels needed for self hosted signup to confirm payment and receive license. +type SelfHostedSignupCustomerResponse struct { + CustomerId string `json:"customer_id"` + SetupIntentId string `json:"setup_intent_id"` + SetupIntentSecret string `json:"setup_intent_secret"` + Progress string `json:"progress"` +} + +// SelfHostedSignupConfirmResponse contains data received on successful self hosted signup +type SelfHostedSignupConfirmResponse struct { + License string `json:"license"` + Progress string `json:"progress"` +} + +type SelfHostedSignupConfirmClientResponse struct { + License map[string]string `json:"license"` + Progress string `json:"progress"` +} + +type SelfHostedBillingAccessRequest struct { + LicenseId string `json:"license_id"` +} + +type SelfHostedBillingAccessResponse struct { + Token string `json:"token"` +} diff --git a/model/insights.go b/model/insights.go index 437cdeec37..22d105f867 100644 --- a/model/insights.go +++ b/model/insights.go @@ -248,6 +248,9 @@ func ToDailyPostCountViewModel(dpc []*DurationPostCount, startTime *time.Time, n return viewModel } +// Deprecated: This method doesn't perform error checking. +// Use GetStartOfDayForTimeRange instead. +// // StartOfDayForTimeRange gets the unix start time in milliseconds from the given time range. // Time range can be one of: "today", "7_day", or "28_day". func StartOfDayForTimeRange(timeRange string, location *time.Location) *time.Time { diff --git a/model/migration.go b/model/migration.go index e0e9ae2267..766e51598a 100644 --- a/model/migration.go +++ b/model/migration.go @@ -39,4 +39,5 @@ const ( MigrationKeyAddCustomUserGroupsPermissions = "custom_groups_permissions" MigrationKeyAddPlayboosksManageRolesPermissions = "playbooks_manage_roles" MigrationKeyAddProductsBoardsPermissions = "products_boards" + MigrationKeyAddCustomUserGroupsPermissionRestore = "custom_groups_permission_restore" ) diff --git a/model/permission.go b/model/permission.go index 76cf07c872..a44a566964 100644 --- a/model/permission.go +++ b/model/permission.go @@ -366,6 +366,7 @@ var PermissionCreateCustomGroup *Permission var PermissionManageCustomGroupMembers *Permission var PermissionEditCustomGroup *Permission var PermissionDeleteCustomGroup *Permission +var PermissionRestoreCustomGroup *Permission var AllPermissions []*Permission var DeprecatedPermissions []*Permission @@ -1960,6 +1961,13 @@ func initializePermissions() { PermissionScopeGroup, } + PermissionRestoreCustomGroup = &Permission{ + "restore_custom_group", + "authentication.permissions.restore_custom_group.name", + "authentication.permissions.restore_custom_group.description", + PermissionScopeGroup, + } + // Playbooks PermissionPublicPlaybookCreate = &Permission{ "playbook_public_create", @@ -2340,6 +2348,7 @@ func initializePermissions() { PermissionManageCustomGroupMembers, PermissionEditCustomGroup, PermissionDeleteCustomGroup, + PermissionRestoreCustomGroup, } DeprecatedPermissions = []*Permission{ diff --git a/model/post.go b/model/post.go index 992188ac8e..2a7c4538e0 100644 --- a/model/post.go +++ b/model/post.go @@ -119,7 +119,7 @@ type Post struct { } func (o *Post) Auditable() map[string]interface{} { - return map[string]interface{}{ // TODO check this + return map[string]interface{}{ "id": o.Id, "create_at": o.CreateAt, "update_at": o.UpdateAt, @@ -195,6 +195,15 @@ func (o *PostPatch) WithRewrittenImageURLs(f func(string) string) *PostPatch { return © } +func (o *PostPatch) Auditable() map[string]interface{} { + return map[string]interface{}{ + "is_pinned": o.IsPinned, + "props": o.Props, + "file_ids": o.FileIds, + "has_reactions": o.HasReactions, + } +} + type PostForExport struct { Post TeamName string diff --git a/model/role.go b/model/role.go index ac3fa3204e..b4a1825537 100644 --- a/model/role.go +++ b/model/role.go @@ -348,6 +348,7 @@ func init() { PermissionCreateCustomGroup.Id, PermissionEditCustomGroup.Id, PermissionDeleteCustomGroup.Id, + PermissionRestoreCustomGroup.Id, PermissionManageCustomGroupMembers.Id, } @@ -953,6 +954,7 @@ func MakeDefaultRoles() map[string]*Role { PermissionCreateCustomGroup.Id, PermissionEditCustomGroup.Id, PermissionDeleteCustomGroup.Id, + PermissionRestoreCustomGroup.Id, PermissionManageCustomGroupMembers.Id, }, SchemeManaged: true, diff --git a/model/version.go b/model/version.go index b2a37dcda6..6090666ca3 100644 --- a/model/version.go +++ b/model/version.go @@ -13,6 +13,7 @@ import ( // It should be maintained in chronological order with most current // release at the front of the list. var versions = []string{ + "7.7.0", "7.6.0", "7.5.0", "7.4.0", diff --git a/model/websocket_message.go b/model/websocket_message.go index 1ad2a3b8b0..f519464d13 100644 --- a/model/websocket_message.go +++ b/model/websocket_message.go @@ -81,6 +81,7 @@ const ( WebsocketEventDraftDeleted = "draft_deleted" WebsocketEventAcknowledgementAdded = "post_acknowledgement_added" WebsocketEventAcknowledgementRemoved = "post_acknowledgement_removed" + WebsocketEventHostedCustomerSignupProgressUpdated = "hosted_customer_signup_progress_updated" ) type WebSocketMessage interface { diff --git a/product/api.go b/product/api.go index 88eb9744e4..5067c20ce9 100644 --- a/product/api.go +++ b/product/api.go @@ -112,7 +112,7 @@ type ConfigService interface { } // HooksService is the API for adding exiting plugin hooks to the server so that they can be called as -// they were. This Service is required to be used after the products start. Otherwise it will return an error. +// they were. This Service is required to be accessed after the channels product initialized. // // The service shall be registered via app.HooksKey service key. type HooksService interface { diff --git a/product/product.go b/product/product.go new file mode 100644 index 0000000000..df084ff015 --- /dev/null +++ b/product/product.go @@ -0,0 +1,24 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package product + +type Product interface { + Start() error + Stop() error +} + +type Manifest struct { + Initializer func(map[ServiceKey]any) (Product, error) + Dependencies map[ServiceKey]struct{} +} + +var products = make(map[string]Manifest) + +func RegisterProduct(name string, m Manifest) { + products[name] = m +} + +func GetProducts() map[string]Manifest { + return products +} diff --git a/product/service.go b/product/service.go new file mode 100644 index 0000000000..221fa1c168 --- /dev/null +++ b/product/service.go @@ -0,0 +1,28 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package product + +type ServiceKey string + +const ( + ChannelKey ServiceKey = "channel" + ConfigKey ServiceKey = "config" + LicenseKey ServiceKey = "license" + FilestoreKey ServiceKey = "filestore" + FileInfoStoreKey ServiceKey = "fileinfostore" + ClusterKey ServiceKey = "cluster" + CloudKey ServiceKey = "cloud" + PostKey ServiceKey = "post" + TeamKey ServiceKey = "team" + UserKey ServiceKey = "user" + PermissionsKey ServiceKey = "permissions" + RouterKey ServiceKey = "router" + BotKey ServiceKey = "bot" + LogKey ServiceKey = "log" + HooksKey ServiceKey = "hooks" + KVStoreKey ServiceKey = "kvstore" + StoreKey ServiceKey = "storekey" + SystemKey ServiceKey = "systemkey" + PreferencesKey ServiceKey = "preferenceskey" +) diff --git a/services/telemetry/mocks/ServerIface.go b/services/telemetry/mocks/ServerIface.go index c56cd87382..f06ce99181 100644 --- a/services/telemetry/mocks/ServerIface.go +++ b/services/telemetry/mocks/ServerIface.go @@ -13,6 +13,8 @@ import ( model "github.com/mattermost/mattermost-server/v6/model" plugin "github.com/mattermost/mattermost-server/v6/plugin" + + product "github.com/mattermost/mattermost-server/v6/product" ) // ServerIface is an autogenerated mock type for the ServerIface type @@ -118,6 +120,22 @@ func (_m *ServerIface) HTTPService() httpservice.HTTPService { return r0 } +// HooksManager provides a mock function with given fields: +func (_m *ServerIface) HooksManager() *product.HooksManager { + ret := _m.Called() + + var r0 *product.HooksManager + if rf, ok := ret.Get(0).(func() *product.HooksManager); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*product.HooksManager) + } + } + + return r0 +} + // IsLeader provides a mock function with given fields: func (_m *ServerIface) IsLeader() bool { ret := _m.Called() diff --git a/services/telemetry/telemetry.go b/services/telemetry/telemetry.go index 67a0ca1209..d0e8f91a19 100644 --- a/services/telemetry/telemetry.go +++ b/services/telemetry/telemetry.go @@ -15,6 +15,7 @@ import ( "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin" + "github.com/mattermost/mattermost-server/v6/product" "github.com/mattermost/mattermost-server/v6/services/httpservice" "github.com/mattermost/mattermost-server/v6/services/marketplace" "github.com/mattermost/mattermost-server/v6/services/searchengine" @@ -93,6 +94,7 @@ type ServerIface interface { License() *model.License GetRoleByName(context.Context, string) (*model.Role, *model.AppError) GetSchemes(string, int, int) ([]*model.Scheme, *model.AppError) + HooksManager() *product.HooksManager } type TelemetryService struct { @@ -167,6 +169,7 @@ func (ts *TelemetryService) sendDailyTelemetry(override bool) { ts.trackGroups() ts.trackChannelModeration() ts.trackWarnMetrics() + ts.trackProducts() } } @@ -457,7 +460,7 @@ func (ts *TelemetryService) trackConfig() { "restrict_link_previews": isDefault(*cfg.ServiceSettings.RestrictLinkPreviews, ""), "enable_custom_groups": *cfg.ServiceSettings.EnableCustomGroups, "post_priority": *cfg.ServiceSettings.PostPriority, - "self_hosted_first_time_purchase": *cfg.ServiceSettings.SelfHostedFirstTimePurchase, + "self_hosted_purchase": *cfg.ServiceSettings.SelfHostedPurchase, "allow_synced_drafts": *cfg.ServiceSettings.AllowSyncedDrafts, }) @@ -946,6 +949,18 @@ func (ts *TelemetryService) trackPlugins() { }, plugin.OnSendDailyTelemetryID) } +func (ts *TelemetryService) trackProducts() { + hm := ts.srv.HooksManager() + if hm == nil { + return + } + + hm.RunMultiHook(func(hooks plugin.Hooks) bool { + hooks.OnSendDailyTelemetry() + return true + }, plugin.OnSendDailyTelemetryID) +} + func (ts *TelemetryService) trackServer() { data := map[string]any{ "edition": model.BuildEnterpriseReady, diff --git a/services/telemetry/telemetry_test.go b/services/telemetry/telemetry_test.go index 389cea5f37..650794eb03 100644 --- a/services/telemetry/telemetry_test.go +++ b/services/telemetry/telemetry_test.go @@ -25,6 +25,7 @@ import ( "github.com/mattermost/mattermost-server/v6/model" "github.com/mattermost/mattermost-server/v6/plugin" "github.com/mattermost/mattermost-server/v6/plugin/plugintest" + "github.com/mattermost/mattermost-server/v6/product" "github.com/mattermost/mattermost-server/v6/services/httpservice" "github.com/mattermost/mattermost-server/v6/services/searchengine" "github.com/mattermost/mattermost-server/v6/services/telemetry/mocks" @@ -189,6 +190,7 @@ func initializeMocks(cfg *model.Config, cloudLicense bool) (*mocks.ServerIface, serverIfaceMock.On("GetRoleByName", context.Background(), "channel_guest").Return(&model.Role{Permissions: []string{"cg-test1", "cg-test2"}}, nil) serverIfaceMock.On("GetSchemes", "team", 0, 100).Return([]*model.Scheme{}, nil) serverIfaceMock.On("HTTPService").Return(httpservice.MakeHTTPService(configService)) + serverIfaceMock.On("HooksManager").Return(product.NewHooksManager(nil)) storeMock := &storeMocks.Store{} storeMock.On("GetDbVersion", false).Return("5.24.0", nil) diff --git a/shared/filestore/filesstore_test.go b/shared/filestore/filesstore_test.go index 9bc9e281d7..c17836558d 100644 --- a/shared/filestore/filesstore_test.go +++ b/shared/filestore/filesstore_test.go @@ -5,9 +5,12 @@ package filestore import ( "bytes" + "context" "fmt" + "io" "math/rand" "os" + "strings" "testing" "time" @@ -121,6 +124,91 @@ func (s *FileBackendTestSuite) TestReadWriteFile() { s.EqualValues(readString, "test") } +func (s *FileBackendTestSuite) TestReadWriteFileContext() { + type ContextWriter interface { + WriteFileContext(context.Context, io.Reader, string) (int64, error) + } + + data := "test" + + s.T().Run("no deadline", func(t *testing.T) { + var ( + written int64 + err error + ) + + path := "tests/" + randomString() + + ctx := context.Background() + if cw, ok := s.backend.(ContextWriter); ok { + written, err = cw.WriteFileContext(ctx, strings.NewReader(data), path) + } else { + written, err = s.backend.WriteFile(strings.NewReader(data), path) + } + s.NoError(err) + s.EqualValues(len(data), written, "expected given number of bytes to have been written") + defer s.backend.RemoveFile(path) + + read, err := s.backend.ReadFile(path) + s.NoError(err) + + readString := string(read) + s.Equal(readString, data) + }) + + s.T().Run("long deadline", func(t *testing.T) { + var ( + written int64 + err error + ) + + path := "tests/" + randomString() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if cw, ok := s.backend.(ContextWriter); ok { + written, err = cw.WriteFileContext(ctx, strings.NewReader(data), path) + } else { + written, err = s.backend.WriteFile(strings.NewReader(data), path) + } + s.NoError(err) + s.EqualValues(len(data), written, "expected given number of bytes to have been written") + defer s.backend.RemoveFile(path) + + read, err := s.backend.ReadFile(path) + s.NoError(err) + + readString := string(read) + s.Equal(readString, data) + }) + + s.T().Run("missed deadline", func(t *testing.T) { + var ( + written int64 + err error + ) + + path := "tests/" + randomString() + + r, w := io.Pipe() + go func() { + // close the writer after a short time + time.Sleep(500 * time.Millisecond) + w.Close() + }() + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + if cw, ok := s.backend.(ContextWriter); ok { + written, err = cw.WriteFileContext(ctx, r, path) + } else { + // this test works only with a context writer + return + } + s.Error(err) + s.Zero(written) + }) +} + func (s *FileBackendTestSuite) TestReadWriteFileImage() { b := []byte("testimage") path := "tests/" + randomString() + ".png" diff --git a/shared/filestore/s3store.go b/shared/filestore/s3store.go index 60132cd830..3dcbbe9b1a 100644 --- a/shared/filestore/s3store.go +++ b/shared/filestore/s3store.go @@ -369,6 +369,13 @@ func (b *S3FileBackend) MoveFile(oldPath, newPath string) error { } func (b *S3FileBackend) WriteFile(fr io.Reader, path string) (int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), b.timeout) + defer cancel() + + return b.WriteFileContext(ctx, fr, path) +} + +func (b *S3FileBackend) WriteFileContext(ctx context.Context, fr io.Reader, path string) (int64, error) { var contentType string path = filepath.Join(b.pathPrefix, path) if ext := filepath.Ext(path); isFileExtImage(ext) { @@ -377,22 +384,26 @@ func (b *S3FileBackend) WriteFile(fr io.Reader, path string) (int64, error) { contentType = "binary/octet-stream" } - ctx, cancel := context.WithTimeout(context.Background(), b.timeout) - defer cancel() options := s3PutOptions(b.encrypt, contentType) - objSize := -1 + objSize := int64(-1) isCloud := os.Getenv("MM_CLOUD_FILESTORE_BIFROST") != "" if isCloud { options.DisableContentSha256 = true - } - // We pass an object size only in situations where bifrost is not - // used. Bifrost needs to run in HTTPS, which is not yet deployed. - if buf, ok := fr.(*bytes.Buffer); ok && !isCloud { - objSize = buf.Len() + } else { + // We pass an object size only in situations where bifrost is not + // used. Bifrost needs to run in HTTPS, which is not yet deployed. + switch t := fr.(type) { + case *bytes.Buffer: + objSize = int64(t.Len()) + case *os.File: + if s, err := t.Stat(); err == nil { + objSize = s.Size() + } + } } - info, err := b.client.PutObject(ctx, b.bucket, path, fr, int64(objSize), options) + info, err := b.client.PutObject(ctx, b.bucket, path, fr, objSize, options) if err != nil { return info.Size, errors.Wrapf(err, "unable write the data in the file %s", path) } diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index 75e832ccf9..546e0664d6 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -4501,6 +4501,24 @@ func (s *OpenTracingLayerGroupStore) GetMemberUsersPage(groupID string, page int return result, err } +func (s *OpenTracingLayerGroupStore) GetMemberUsersSortedPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions, teammateNameDisplay string) ([]*model.User, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "GroupStore.GetMemberUsersSortedPage") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.GroupStore.GetMemberUsersSortedPage(groupID, page, perPage, viewRestrictions, teammateNameDisplay) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerGroupStore) GetNonMemberUsersPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "GroupStore.GetNonMemberUsersPage") @@ -10612,7 +10630,7 @@ func (s *OpenTracingLayerUploadSessionStore) Delete(id string) error { return err } -func (s *OpenTracingLayerUploadSessionStore) Get(id string) (*model.UploadSession, error) { +func (s *OpenTracingLayerUploadSessionStore) Get(ctx context.Context, id string) (*model.UploadSession, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "UploadSessionStore.Get") s.Root.Store.SetContext(newCtx) @@ -10621,7 +10639,7 @@ func (s *OpenTracingLayerUploadSessionStore) Get(id string) (*model.UploadSessio }() defer span.Finish() - result, err := s.UploadSessionStore.Get(id) + result, err := s.UploadSessionStore.Get(ctx, id) if err != nil { span.LogFields(spanlog.Error(err)) ext.Error.Set(span, true) diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index 426cc15487..e23160cf77 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -5070,6 +5070,27 @@ func (s *RetryLayerGroupStore) GetMemberUsersPage(groupID string, page int, perP } +func (s *RetryLayerGroupStore) GetMemberUsersSortedPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions, teammateNameDisplay string) ([]*model.User, error) { + + tries := 0 + for { + result, err := s.GroupStore.GetMemberUsersSortedPage(groupID, page, perPage, viewRestrictions, teammateNameDisplay) + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerGroupStore) GetNonMemberUsersPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, error) { tries := 0 @@ -12126,11 +12147,11 @@ func (s *RetryLayerUploadSessionStore) Delete(id string) error { } -func (s *RetryLayerUploadSessionStore) Get(id string) (*model.UploadSession, error) { +func (s *RetryLayerUploadSessionStore) Get(ctx context.Context, id string) (*model.UploadSession, error) { tries := 0 for { - result, err := s.UploadSessionStore.Get(id) + result, err := s.UploadSessionStore.Get(ctx, id) if err == nil { return result, nil } diff --git a/store/sqlstore/group_store.go b/store/sqlstore/group_store.go index 64737840b8..8d160ef7a8 100644 --- a/store/sqlstore/group_store.go +++ b/store/sqlstore/group_store.go @@ -421,22 +421,56 @@ func (s *SqlGroupStore) GetMemberUsers(groupID string) ([]*model.User, error) { } func (s *SqlGroupStore) GetMemberUsersPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, error) { + return s.GetMemberUsersSortedPage(groupID, page, perPage, viewRestrictions, model.ShowUsername) +} + +func (s *SqlGroupStore) GetMemberUsersSortedPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions, teammateNameDisplay string) ([]*model.User, error) { groupMembers := []*model.User{} - query := s.getQueryBuilder(). - Select("u.*"). + userQuery := s.getQueryBuilder(). + Select(`u.*`). From("GroupMembers"). Join("Users u ON u.Id = GroupMembers.UserId"). Where(sq.Eq{"GroupMembers.DeleteAt": 0}). Where(sq.Eq{"u.DeleteAt": 0}). - Where(sq.Eq{"GroupId": groupID}). + Where(sq.Eq{"GroupId": groupID}) + + userQuery = applyViewRestrictionsFilter(userQuery, viewRestrictions, true) + queryString, args, err := userQuery.ToSql() + if err != nil { + return nil, errors.Wrap(err, "") + } + + orderQuery := s.getQueryBuilder(). + Select("u.*"). + From("(" + queryString + ") AS u") + + if teammateNameDisplay == model.ShowNicknameFullName { + orderQuery = orderQuery.OrderBy(` + CASE + WHEN u.Nickname != '' THEN u.Nickname + WHEN u.FirstName != '' AND u.LastName != '' THEN CONCAT(u.FirstName, ' ', u.LastName) + WHEN u.FirstName != '' THEN u.FirstName + WHEN u.LastName != '' THEN u.LastName + ELSE u.Username + END`) + } else if teammateNameDisplay == model.ShowFullName { + orderQuery = orderQuery.OrderBy(` + CASE + WHEN u.FirstName != '' AND u.LastName != '' THEN CONCAT(u.FirstName, ' ', u.LastName) + WHEN u.FirstName != '' THEN u.FirstName + WHEN u.LastName != '' THEN u.LastName + ELSE u.Username + END`) + } else { + orderQuery = orderQuery.OrderBy("u.Username") + } + + orderQuery = orderQuery. Limit(uint64(perPage)). - Offset(uint64(page * perPage)). - OrderBy("u.CreateAt DESC") + Offset(uint64(page * perPage)) - query = applyViewRestrictionsFilter(query, viewRestrictions, true) - - queryString, args, err := query.ToSql() + queryString, _, err = orderQuery.ToSql() if err != nil { return nil, errors.Wrap(err, "") } @@ -463,7 +497,7 @@ func (s *SqlGroupStore) GetNonMemberUsersPage(groupID string, page int, perPage Where("(GroupMembers.UserID IS NULL OR GroupMembers.DeleteAt != 0)"). Limit(uint64(perPage)). Offset(uint64(page * perPage)). - OrderBy("u.CreateAt DESC") + OrderBy("u.Username ASC") query = applyViewRestrictionsFilter(query, viewRestrictions, true) diff --git a/store/sqlstore/upload_session_store.go b/store/sqlstore/upload_session_store.go index e22b2056f3..3efec8f239 100644 --- a/store/sqlstore/upload_session_store.go +++ b/store/sqlstore/upload_session_store.go @@ -4,6 +4,7 @@ package sqlstore import ( + "context" "database/sql" sq "github.com/mattermost/squirrel" @@ -78,7 +79,7 @@ func (us SqlUploadSessionStore) Update(session *model.UploadSession) error { return nil } -func (us SqlUploadSessionStore) Get(id string) (*model.UploadSession, error) { +func (us SqlUploadSessionStore) Get(ctx context.Context, id string) (*model.UploadSession, error) { if !model.IsValidId(id) { return nil, errors.New("SqlUploadSessionStore.Get: id is not valid") } @@ -91,7 +92,7 @@ func (us SqlUploadSessionStore) Get(id string) (*model.UploadSession, error) { return nil, errors.Wrap(err, "SqlUploadSessionStore.Get: failed to build query") } var session model.UploadSession - if err := us.GetReplicaX().Get(&session, query, args...); err != nil { + if err := us.DBXFromContext(ctx).Get(&session, query, args...); err != nil { if err == sql.ErrNoRows { return nil, store.NewErrNotFound("UploadSession", id) } diff --git a/store/store.go b/store/store.go index 3014d7bcf6..b57aeba676 100644 --- a/store/store.go +++ b/store/store.go @@ -717,7 +717,7 @@ type FileInfoStore interface { type UploadSessionStore interface { Save(session *model.UploadSession) (*model.UploadSession, error) Update(session *model.UploadSession) error - Get(id string) (*model.UploadSession, error) + Get(ctx context.Context, id string) (*model.UploadSession, error) GetForUser(userID string) ([]*model.UploadSession, error) Delete(id string) error } @@ -845,6 +845,7 @@ type GroupStore interface { GetMemberUsers(groupID string) ([]*model.User, error) GetMemberUsersPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, error) + GetMemberUsersSortedPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions, teammateNameDisplay string) ([]*model.User, error) GetMemberCountWithRestrictions(groupID string, viewRestrictions *model.ViewUsersRestrictions) (int64, error) GetMemberCount(groupID string) (int64, error) diff --git a/store/storetest/group_store.go b/store/storetest/group_store.go index 02d788765d..2b60e829f9 100644 --- a/store/storetest/group_store.go +++ b/store/storetest/group_store.go @@ -36,6 +36,7 @@ func TestGroupStore(t *testing.T, ss store.Store) { t.Run("GetMemberUsers", func(t *testing.T) { testGroupGetMemberUsers(t, ss) }) t.Run("GetMemberUsersPage", func(t *testing.T) { testGroupGetMemberUsersPage(t, ss) }) + t.Run("GetMemberUsersSortedPage", func(t *testing.T) { testGroupGetMemberUsersSortedPage(t, ss) }) t.Run("GetMemberUsersInTeam", func(t *testing.T) { testGroupGetMemberUsersInTeam(t, ss) }) t.Run("GetMemberUsersNotInChannel", func(t *testing.T) { testGroupGetMemberUsersNotInChannel(t, ss) }) @@ -861,7 +862,7 @@ func testGroupGetMemberUsersPage(t *testing.T, ss store.Store) { u1 := &model.User{ Email: MakeEmail(), - Username: model.NewId(), + Username: "user1" + model.NewId(), } user1, nErr := ss.User().Save(u1) require.NoError(t, nErr) @@ -871,7 +872,7 @@ func testGroupGetMemberUsersPage(t *testing.T, ss store.Store) { u2 := &model.User{ Email: MakeEmail(), - Username: model.NewId(), + Username: "user2" + model.NewId(), } user2, nErr := ss.User().Save(u2) require.NoError(t, nErr) @@ -881,7 +882,7 @@ func testGroupGetMemberUsersPage(t *testing.T, ss store.Store) { u3 := &model.User{ Email: MakeEmail(), - Username: model.NewId(), + Username: "user3" + model.NewId(), } user3, nErr := ss.User().Save(u3) require.NoError(t, nErr) @@ -898,13 +899,13 @@ func testGroupGetMemberUsersPage(t *testing.T, ss store.Store) { groupMembers, err = ss.Group().GetMemberUsersPage(group.Id, 0, 2, nil) require.NoError(t, err) require.Equal(t, 2, len(groupMembers)) - require.ElementsMatch(t, []*model.User{user2, user3}, groupMembers) + require.ElementsMatch(t, []*model.User{user1, user2}, groupMembers) // Check page 2 groupMembers, err = ss.Group().GetMemberUsersPage(group.Id, 1, 2, nil) require.NoError(t, err) require.Equal(t, 1, len(groupMembers)) - require.ElementsMatch(t, []*model.User{user1}, groupMembers) + require.ElementsMatch(t, []*model.User{user3}, groupMembers) // Check madeup id groupMembers, err = ss.Group().GetMemberUsersPage(model.NewId(), 0, 100, nil) @@ -921,6 +922,87 @@ func testGroupGetMemberUsersPage(t *testing.T, ss store.Store) { require.Equal(t, 2, len(groupMembers)) } +func testGroupGetMemberUsersSortedPage(t *testing.T, ss store.Store) { + // Save a group + g1 := &model.Group{ + Name: model.NewString(model.NewId()), + DisplayName: model.NewId(), + Description: model.NewId(), + Source: model.GroupSourceLdap, + RemoteId: model.NewString(model.NewId()), + } + group, err := ss.Group().Create(g1) + require.NoError(t, err) + + // First by nickname, third by full name, second by username + u1 := &model.User{ + Email: MakeEmail(), + Username: "y" + model.NewId(), + Nickname: "a" + model.NewId(), + FirstName: "z" + model.NewId(), + LastName: "z" + model.NewId(), + } + user1, nErr := ss.User().Save(u1) + require.NoError(t, nErr) + + _, err = ss.Group().UpsertMember(group.Id, user1.Id) + require.NoError(t, err) + + // Second by nickname, first by full name, third by username + u2 := &model.User{ + Email: MakeEmail(), + Username: "z" + model.NewId(), + FirstName: "b" + model.NewId(), + LastName: "b" + model.NewId(), + } + user2, nErr := ss.User().Save(u2) + require.NoError(t, nErr) + + _, err = ss.Group().UpsertMember(group.Id, user2.Id) + require.NoError(t, err) + + // Third by nickname, second by full name, first by username + u3 := &model.User{ + Email: MakeEmail(), + Username: "d" + model.NewId(), + } + user3, nErr := ss.User().Save(u3) + require.NoError(t, nErr) + + _, err = ss.Group().UpsertMember(group.Id, user3.Id) + require.NoError(t, err) + + // Check nickname ordering, paged + groupMembers, err := ss.Group().GetMemberUsersSortedPage(group.Id, 0, 2, nil, model.ShowNicknameFullName) + require.NoError(t, err) + require.Equal(t, 2, len(groupMembers)) + require.ElementsMatch(t, []*model.User{user1, user2}, groupMembers) + groupMembers, err = ss.Group().GetMemberUsersSortedPage(group.Id, 1, 2, nil, model.ShowNicknameFullName) + require.NoError(t, err) + require.Equal(t, 1, len(groupMembers)) + require.ElementsMatch(t, []*model.User{user3}, groupMembers) + + // Check full name ordering, paged + groupMembers, err = ss.Group().GetMemberUsersSortedPage(group.Id, 0, 2, nil, model.ShowFullName) + require.NoError(t, err) + require.Equal(t, 2, len(groupMembers)) + require.ElementsMatch(t, []*model.User{user2, user3}, groupMembers) + groupMembers, err = ss.Group().GetMemberUsersSortedPage(group.Id, 1, 2, nil, model.ShowFullName) + require.NoError(t, err) + require.Equal(t, 1, len(groupMembers)) + require.ElementsMatch(t, []*model.User{user1}, groupMembers) + + // Check username ordering + groupMembers, err = ss.Group().GetMemberUsersSortedPage(group.Id, 0, 2, nil, model.ShowUsername) + require.NoError(t, err) + require.Equal(t, 2, len(groupMembers)) + require.ElementsMatch(t, []*model.User{user3, user1}, groupMembers) + groupMembers, err = ss.Group().GetMemberUsersSortedPage(group.Id, 1, 2, nil, model.ShowUsername) + require.NoError(t, err) + require.Equal(t, 1, len(groupMembers)) + require.ElementsMatch(t, []*model.User{user2}, groupMembers) +} + func testGroupGetMemberUsersInTeam(t *testing.T, ss store.Store) { // Save a team team := &model.Team{ diff --git a/store/storetest/mocks/GroupStore.go b/store/storetest/mocks/GroupStore.go index 7f54c5487b..e77e2a8628 100644 --- a/store/storetest/mocks/GroupStore.go +++ b/store/storetest/mocks/GroupStore.go @@ -826,6 +826,29 @@ func (_m *GroupStore) GetMemberUsersPage(groupID string, page int, perPage int, return r0, r1 } +// GetMemberUsersSortedPage provides a mock function with given fields: groupID, page, perPage, viewRestrictions, teammateNameDisplay +func (_m *GroupStore) GetMemberUsersSortedPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions, teammateNameDisplay string) ([]*model.User, error) { + ret := _m.Called(groupID, page, perPage, viewRestrictions, teammateNameDisplay) + + var r0 []*model.User + if rf, ok := ret.Get(0).(func(string, int, int, *model.ViewUsersRestrictions, string) []*model.User); ok { + r0 = rf(groupID, page, perPage, viewRestrictions, teammateNameDisplay) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.User) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, int, int, *model.ViewUsersRestrictions, string) error); ok { + r1 = rf(groupID, page, perPage, viewRestrictions, teammateNameDisplay) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetNonMemberUsersPage provides a mock function with given fields: groupID, page, perPage, viewRestrictions func (_m *GroupStore) GetNonMemberUsersPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, error) { ret := _m.Called(groupID, page, perPage, viewRestrictions) diff --git a/store/storetest/mocks/UploadSessionStore.go b/store/storetest/mocks/UploadSessionStore.go index dcb8129e9b..f27dbf6856 100644 --- a/store/storetest/mocks/UploadSessionStore.go +++ b/store/storetest/mocks/UploadSessionStore.go @@ -5,6 +5,8 @@ package mocks import ( + context "context" + model "github.com/mattermost/mattermost-server/v6/model" mock "github.com/stretchr/testify/mock" ) @@ -28,13 +30,13 @@ func (_m *UploadSessionStore) Delete(id string) error { return r0 } -// Get provides a mock function with given fields: id -func (_m *UploadSessionStore) Get(id string) (*model.UploadSession, error) { - ret := _m.Called(id) +// Get provides a mock function with given fields: ctx, id +func (_m *UploadSessionStore) Get(ctx context.Context, id string) (*model.UploadSession, error) { + ret := _m.Called(ctx, id) var r0 *model.UploadSession - if rf, ok := ret.Get(0).(func(string) *model.UploadSession); ok { - r0 = rf(id) + if rf, ok := ret.Get(0).(func(context.Context, string) *model.UploadSession); ok { + r0 = rf(ctx, id) } else { if ret.Get(0) != nil { r0 = ret.Get(0).(*model.UploadSession) @@ -42,8 +44,8 @@ func (_m *UploadSessionStore) Get(id string) (*model.UploadSession, error) { } var r1 error - if rf, ok := ret.Get(1).(func(string) error); ok { - r1 = rf(id) + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, id) } else { r1 = ret.Error(1) } diff --git a/store/storetest/upload_session_store.go b/store/storetest/upload_session_store.go index ab2178a27f..55d4f89927 100644 --- a/store/storetest/upload_session_store.go +++ b/store/storetest/upload_session_store.go @@ -4,6 +4,7 @@ package storetest import ( + "context" "testing" "time" @@ -52,13 +53,13 @@ func testUploadSessionStoreSaveGet(t *testing.T, ss store.Store) { }) t.Run("getting non-existing session should fail", func(t *testing.T) { - us, err := ss.UploadSession().Get("fake") + us, err := ss.UploadSession().Get(context.Background(), "fake") require.Error(t, err) require.Nil(t, us) }) t.Run("getting existing session should succeed", func(t *testing.T) { - us, err := ss.UploadSession().Get(session.Id) + us, err := ss.UploadSession().Get(context.Background(), session.Id) require.NoError(t, err) require.NotNil(t, us) require.Equal(t, session, us) @@ -100,7 +101,7 @@ func testUploadSessionStoreUpdate(t *testing.T, ss store.Store) { err = ss.UploadSession().Update(us) require.NoError(t, err) - updated, err := ss.UploadSession().Get(us.Id) + updated, err := ss.UploadSession().Get(context.Background(), us.Id) require.NoError(t, err) require.NotNil(t, us) require.Equal(t, us, updated) @@ -199,7 +200,7 @@ func testUploadSessionStoreDelete(t *testing.T, ss store.Store) { err = ss.UploadSession().Delete(session.Id) require.NoError(t, err) - us, err = ss.UploadSession().Get(us.Id) + us, err = ss.UploadSession().Get(context.Background(), us.Id) require.Error(t, err) require.Nil(t, us) require.IsType(t, &store.ErrNotFound{}, err) diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index cff6c65e91..8161ad126a 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -4098,6 +4098,22 @@ func (s *TimerLayerGroupStore) GetMemberUsersPage(groupID string, page int, perP return result, err } +func (s *TimerLayerGroupStore) GetMemberUsersSortedPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions, teammateNameDisplay string) ([]*model.User, error) { + start := time.Now() + + result, err := s.GroupStore.GetMemberUsersSortedPage(groupID, page, perPage, viewRestrictions, teammateNameDisplay) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("GroupStore.GetMemberUsersSortedPage", success, elapsed) + } + return result, err +} + func (s *TimerLayerGroupStore) GetNonMemberUsersPage(groupID string, page int, perPage int, viewRestrictions *model.ViewUsersRestrictions) ([]*model.User, error) { start := time.Now() @@ -9549,10 +9565,10 @@ func (s *TimerLayerUploadSessionStore) Delete(id string) error { return err } -func (s *TimerLayerUploadSessionStore) Get(id string) (*model.UploadSession, error) { +func (s *TimerLayerUploadSessionStore) Get(ctx context.Context, id string) (*model.UploadSession, error) { start := time.Now() - result, err := s.UploadSessionStore.Get(id) + result, err := s.UploadSessionStore.Get(ctx, id) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { diff --git a/templates/cloud_45_day_arrears.html b/templates/cloud_45_day_arrears.html index f868f37ab4..58d7859d95 100644 --- a/templates/cloud_45_day_arrears.html +++ b/templates/cloud_45_day_arrears.html @@ -437,7 +437,7 @@ diff --git a/templates/cloud_90_day_arrears.html b/templates/cloud_90_day_arrears.html index 21769839ca..5bdeacbab7 100644 --- a/templates/cloud_90_day_arrears.html +++ b/templates/cloud_90_day_arrears.html @@ -437,7 +437,7 @@
- + {{.Props.SecondaryActionButtonText}}
diff --git a/templates/partials/cloud_title_3subtitles_button.mjml b/templates/partials/cloud_title_3subtitles_button.mjml index 38c39071af..5ac4edf7e9 100644 --- a/templates/partials/cloud_title_3subtitles_button.mjml +++ b/templates/partials/cloud_title_3subtitles_button.mjml @@ -16,7 +16,7 @@ {{.Props.Button}} {{if .IncludeSecondaryActionButton}} - + {{.Props.SecondaryActionButtonText}} {{end}} diff --git a/templates/payment_failed_no_card_body.html b/templates/payment_failed_no_card_body.html index 6966bf296b..ec7d4d5de6 100644 --- a/templates/payment_failed_no_card_body.html +++ b/templates/payment_failed_no_card_body.html @@ -1,7 +1,7 @@ {{define "payment_failed_no_card_body"}} -
- + {{.Props.SecondaryActionButtonText}}
@@ -18,23 +18,16 @@
- + style="border-collapse: collapse"> +
+ style="padding: 20px 0 0; text-align: center; margin: 0 auto; max-width: 443px"> -
- - - - -
-

- {{ .Props.Title }}

-
+
+

+ {{ .Props.Title }}

{{ .Props.Info1 }}

@@ -50,7 +43,7 @@
- +
@@ -65,7 +58,7 @@
- +
diff --git a/testlib/store.go b/testlib/store.go index 800764da07..9f70dfa8cd 100644 --- a/testlib/store.go +++ b/testlib/store.go @@ -68,6 +68,7 @@ func GetMockStoreForSetupFunctions() *mocks.Store { systemStore.On("GetByName", model.MigrationKeyAddPlaybooksPermissions).Return(&model.System{Name: model.MigrationKeyAddPlaybooksPermissions, Value: "true"}, nil) systemStore.On("GetByName", model.MigrationKeyAddCustomUserGroupsPermissions).Return(&model.System{Name: model.MigrationKeyAddCustomUserGroupsPermissions, Value: "true"}, nil) systemStore.On("GetByName", model.MigrationKeyAddPlayboosksManageRolesPermissions).Return(&model.System{Name: model.MigrationKeyAddPlayboosksManageRolesPermissions, Value: "true"}, nil) + systemStore.On("GetByName", model.MigrationKeyAddCustomUserGroupsPermissionRestore).Return(&model.System{Name: model.MigrationKeyAddCustomUserGroupsPermissionRestore, Value: "true"}, nil) systemStore.On("GetByName", "CustomGroupAdminRoleCreationMigrationComplete").Return(&model.System{Name: model.MigrationKeyAddPlayboosksManageRolesPermissions, Value: "true"}, nil) systemStore.On("GetByName", "products_boards").Return(&model.System{Name: "products_boards", Value: "true"}, nil) systemStore.On("InsertIfExists", mock.AnythingOfType("*model.System")).Return(&model.System{}, nil).Once() diff --git a/web/handlers.go b/web/handlers.go index 798831bc73..333e3906e5 100644 --- a/web/handlers.go +++ b/web/handlers.go @@ -236,7 +236,7 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } cloudCSP := "" - if c.App.Channels().License().IsCloud() || *c.App.Config().ServiceSettings.SelfHostedFirstTimePurchase { + if c.App.Channels().License().IsCloud() || *c.App.Config().ServiceSettings.SelfHostedPurchase { cloudCSP = " js.stripe.com/v3" } diff --git a/web/handlers_test.go b/web/handlers_test.go index e04be652da..ba49272c49 100644 --- a/web/handlers_test.go +++ b/web/handlers_test.go @@ -298,6 +298,29 @@ func TestHandlerServeCSPHeader(t *testing.T) { IsStatic: true, } + request := httptest.NewRequest("POST", "/", nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + assert.Equal(t, 200, response.Code) + assert.Equal(t, []string{"frame-ancestors 'self'; script-src 'self' cdn.rudderlabs.com js.stripe.com/v3"}, response.Header()["Content-Security-Policy"]) + }) + + t.Run("static, without subpath or SelfHostedPurchase, does not allow Stripe in CSP", func(t *testing.T) { + th := Setup(t).InitBasic() + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SelfHostedPurchase = false }) + defer th.TearDown() + + web := New(th.Server) + + handler := Handler{ + Srv: web.srv, + HandleFunc: handlerForCSPHeader, + RequireSession: false, + TrustRequester: false, + RequireMfa: false, + IsStatic: true, + } + request := httptest.NewRequest("POST", "/", nil) response := httptest.NewRecorder() handler.ServeHTTP(response, request) @@ -343,7 +366,7 @@ func TestHandlerServeCSPHeader(t *testing.T) { response := httptest.NewRecorder() handler.ServeHTTP(response, request) assert.Equal(t, 200, response.Code) - assert.Equal(t, []string{"frame-ancestors 'self'; script-src 'self' cdn.rudderlabs.com"}, response.Header()["Content-Security-Policy"]) + assert.Equal(t, []string{"frame-ancestors 'self'; script-src 'self' cdn.rudderlabs.com js.stripe.com/v3"}, response.Header()["Content-Security-Policy"]) // TODO: It's hard to unit test this now that the CSP directive is effectively // decided in Setup(). Circle back to this in master once the memory store is @@ -358,7 +381,7 @@ func TestHandlerServeCSPHeader(t *testing.T) { response = httptest.NewRecorder() handler.ServeHTTP(response, request) assert.Equal(t, 200, response.Code) - assert.Equal(t, []string{"frame-ancestors 'self'; script-src 'self' cdn.rudderlabs.com"}, response.Header()["Content-Security-Policy"]) + assert.Equal(t, []string{"frame-ancestors 'self'; script-src 'self' cdn.rudderlabs.com js.stripe.com/v3"}, response.Header()["Content-Security-Policy"]) // TODO: See above. // assert.Contains(t, response.Header()["Content-Security-Policy"], "frame-ancestors 'self'; script-src 'self' cdn.rudderlabs.com 'sha256-tPOjw+tkVs9axL78ZwGtYl975dtyPHB6LYKAO2R3gR4='", "csp header incorrectly changed after subpath changed") }) @@ -388,7 +411,7 @@ func TestHandlerServeCSPHeader(t *testing.T) { response := httptest.NewRecorder() handler.ServeHTTP(response, request) assert.Equal(t, 200, response.Code) - assert.Equal(t, []string{"frame-ancestors 'self'; script-src 'self' cdn.rudderlabs.com 'unsafe-eval' 'unsafe-inline' http://localhost:9006"}, response.Header()["Content-Security-Policy"]) + assert.Equal(t, []string{"frame-ancestors 'self'; script-src 'self' cdn.rudderlabs.com js.stripe.com/v3 'unsafe-eval' 'unsafe-inline' http://localhost:9006"}, response.Header()["Content-Security-Policy"]) }) } diff --git a/web/static.go b/web/static.go index d354962215..a694f747db 100644 --- a/web/static.go +++ b/web/static.go @@ -81,7 +81,11 @@ func staticFilesHandler(handler http.Handler) http.Handler { //wrap our ResponseWriter with our no-cache 404-handler w = ¬FoundNoCacheResponseWriter{ResponseWriter: w} - w.Header().Set("Cache-Control", "max-age=31556926, public") + if path.Base(r.URL.Path) == "remote_entry.js" { + w.Header().Set("Cache-Control", "no-cache, max-age=31556926, public") + } else { + w.Header().Set("Cache-Control", "max-age=31556926, public") + } if strings.HasSuffix(r.URL.Path, "/") { http.NotFound(w, r) diff --git a/web/web_test.go b/web/web_test.go index ae8e322f91..4214649031 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -8,6 +8,8 @@ import ( "net/http" "net/http/httptest" "os" + "os/exec" + "path" "path/filepath" "testing" "time" @@ -359,6 +361,65 @@ func TestStatic(t *testing.T) { } */ +func TestStaticFilesCaching(t *testing.T) { + th := Setup(t).InitPlugins() + defer th.TearDown() + + wd, _ := os.Getwd() + cmd := exec.Command("ls", path.Join(wd, "client", "plugins")) + cmd.Stdout = os.Stdout + cmd.Run() + + fakeMainBundleName := "main.1234ab.js" + fakeRootHTML := ` + + Mattermost + +` + fakeMainBundle := `module.exports = 'main';` + fakeRemoteEntry := `module.exports = 'remote';` + + err := os.WriteFile("./client/root.html", []byte(fakeRootHTML), 0600) + require.NoError(t, err) + err = os.WriteFile("./client/"+fakeMainBundleName, []byte(fakeMainBundle), 0600) + require.NoError(t, err) + err = os.WriteFile("./client/remote_entry.js", []byte(fakeRemoteEntry), 0600) + require.NoError(t, err) + + err = os.MkdirAll("./client/products/boards", 0777) + require.NoError(t, err) + err = os.WriteFile("./client/products/boards/remote_entry.js", []byte(fakeRemoteEntry), 0600) + require.NoError(t, err) + + req, _ := http.NewRequest("GET", "/", nil) + res := httptest.NewRecorder() + th.Web.MainRouter.ServeHTTP(res, req) + require.Equal(t, http.StatusOK, res.Code) + require.Equal(t, fakeRootHTML, res.Body.String()) + require.Equal(t, []string{"no-cache, max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")]) + + req, _ = http.NewRequest("GET", "/static/"+fakeMainBundleName, nil) + res = httptest.NewRecorder() + th.Web.MainRouter.ServeHTTP(res, req) + require.Equal(t, http.StatusOK, res.Code) + require.Equal(t, fakeMainBundle, res.Body.String()) + require.Equal(t, []string{"max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")]) + + req, _ = http.NewRequest("GET", "/static/remote_entry.js", nil) + res = httptest.NewRecorder() + th.Web.MainRouter.ServeHTTP(res, req) + require.Equal(t, http.StatusOK, res.Code) + require.Equal(t, fakeRemoteEntry, res.Body.String()) + require.Equal(t, []string{"no-cache, max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")]) + + req, _ = http.NewRequest("GET", "/static/products/boards/remote_entry.js", nil) + res = httptest.NewRecorder() + th.Web.MainRouter.ServeHTTP(res, req) + require.Equal(t, http.StatusOK, res.Code) + require.Equal(t, fakeRemoteEntry, res.Body.String()) + require.Equal(t, []string{"no-cache, max-age=31556926, public"}, res.Result().Header[http.CanonicalHeaderKey("Cache-Control")]) +} + func TestCheckClientCompatability(t *testing.T) { //Browser Name, UA String, expected result (if the browser should fail the test false and if it should pass the true) type uaTest struct {