diff --git a/api4/api.go b/api4/api.go index ea4b72119a..d94e2d450b 100644 --- a/api4/api.go +++ b/api4/api.go @@ -140,6 +140,8 @@ type Routes struct { Usage *mux.Router // 'api/v4/usage' + HostedCustomer *mux.Router // 'api/v4/hosted_customer' + Drafts *mux.Router // 'api/v4/drafts' } @@ -267,6 +269,8 @@ func Init(srv *app.Server) (*API, error) { api.BaseRoutes.Usage = api.BaseRoutes.APIRoot.PathPrefix("/usage").Subrouter() + api.BaseRoutes.HostedCustomer = api.BaseRoutes.APIRoot.PathPrefix("/hosted_customer").Subrouter() + api.BaseRoutes.Drafts = api.BaseRoutes.APIRoot.PathPrefix("/drafts").Subrouter() api.InitUser() @@ -312,6 +316,7 @@ func Init(srv *app.Server) (*API, error) { api.InitExport() api.InitInsights() api.InitUsage() + api.InitHostedCustomer() api.InitDrafts() if err := api.InitGraphQL(); err != nil { return nil, err diff --git a/api4/hosted_customer.go b/api4/hosted_customer.go new file mode 100644 index 0000000000..6e696967c6 --- /dev/null +++ b/api4/hosted_customer.go @@ -0,0 +1,73 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost-server/v6/model" +) + +// 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/bootstrap + api.BaseRoutes.HostedCustomer.Handle("/bootstrap", api.APISessionRequired(selfHostedBootstrap)).Methods("POST") +} + +func ensureSelfHostedAdmin(c *Context, where string) { + license := c.App.Channels().License() + + if license.IsCloud() { + c.Err = model.NewAppError(where, "api.cloud.license_error", nil, "Cloud installations do not use this endpoint", http.StatusBadRequest) + return + } + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) { + c.SetPermissionError(model.PermissionSysconsoleWriteBilling) + return + } +} + +func checkSelfHostedFirstTimePurchaseEnabled(c *Context) bool { + config := c.App.Config() + if config == nil { + return false + } + enabled := config.ServiceSettings.SelfHostedFirstTimePurchase + return enabled != nil && *enabled +} + +func selfHostedBootstrap(c *Context, w http.ResponseWriter, r *http.Request) { + where := "Api4.selfHostedBootstrap" + if !checkSelfHostedFirstTimePurchaseEnabled(c) { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) + return + } + ensureSelfHostedAdmin(c, where) + if c.Err != nil { + return + } + + user, userErr := c.App.GetUser(c.AppContext.Session().UserId) + if userErr != nil { + c.Err = userErr + return + } + + signupProgress, err := c.App.Cloud().BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: user.Email}) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError) + return + } + json, err := json.Marshal(signupProgress) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError) + return + } + + w.Write(json) +} diff --git a/api4/hosted_customer_test.go b/api4/hosted_customer_test.go new file mode 100644 index 0000000000..6eff1e922d --- /dev/null +++ b/api4/hosted_customer_test.go @@ -0,0 +1,100 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "net/http" + "os" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/v6/einterfaces/mocks" + "github.com/mattermost/mattermost-server/v6/model" +) + +var valFalse = false +var valTrue = true + +func TestSelfHostedBootstrap(t *testing.T) { + t.Run("feature flag off returns not implemented", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.SystemAdminUser.Email, th.SystemAdminUser.Password) + + 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.ReloadConfig() + + _, r, err := th.Client.BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) + + require.Equal(t, http.StatusNotImplemented, r.StatusCode) + require.Error(t, err) + }) + + t.Run("cloud instances not allowed to bootstrap self-hosted signup", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.SystemAdminUser.Email, th.SystemAdminUser.Password) + + 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.ReloadConfig() + + _, r, err := th.Client.BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) + + require.Equal(t, http.StatusBadRequest, r.StatusCode) + require.Error(t, err) + }) + + t.Run("non-admins not allowed to bootstrap self-hosted signup", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.BasicUser.Email, th.BasicUser.Password) + + 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.ReloadConfig() + + _, r, err := th.Client.BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) + + require.Equal(t, http.StatusForbidden, r.StatusCode) + require.Error(t, err) + }) + + t.Run("self-hosted admins can bootstrap self-hosted signup", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.Client.Login(th.SystemAdminUser.Email, th.SystemAdminUser.Password) + + 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.ReloadConfig() + cloud := mocks.CloudInterface{} + + cloud.Mock.On("BootstrapSelfHostedSignup", mock.Anything).Return(&model.BootstrapSelfHostedSignupResponse{Progress: "START"}, nil) + + cloudImpl := th.App.Srv().Cloud + defer func() { + th.App.Srv().Cloud = cloudImpl + }() + th.App.Srv().Cloud = &cloud + + response, r, err := th.Client.BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) + + require.Equal(t, http.StatusOK, r.StatusCode) + require.NoError(t, err) + require.Equal(t, "START", response.Progress) + }) +} diff --git a/einterfaces/cloud.go b/einterfaces/cloud.go index 36b302a321..8fa16ad023 100644 --- a/einterfaces/cloud.go +++ b/einterfaces/cloud.go @@ -32,6 +32,7 @@ type CloudInterface interface { GetLicenseRenewalStatus(userID, token string) error InvalidateCaches() error + BootstrapSelfHostedSignup(req model.BootstrapSelfHostedSignupRequest) (*model.BootstrapSelfHostedSignupResponse, error) CreateOrUpdateSubscriptionHistoryEvent(userID string, userCount int) (*model.SubscriptionHistory, error) HandleLicenseChange() error } diff --git a/einterfaces/mocks/CloudInterface.go b/einterfaces/mocks/CloudInterface.go index be0e1801d8..05b8fe86d7 100644 --- a/einterfaces/mocks/CloudInterface.go +++ b/einterfaces/mocks/CloudInterface.go @@ -14,6 +14,29 @@ type CloudInterface struct { mock.Mock } +// BootstrapSelfHostedSignup provides a mock function with given fields: req +func (_m *CloudInterface) BootstrapSelfHostedSignup(req model.BootstrapSelfHostedSignupRequest) (*model.BootstrapSelfHostedSignupResponse, error) { + ret := _m.Called(req) + + var r0 *model.BootstrapSelfHostedSignupResponse + if rf, ok := ret.Get(0).(func(model.BootstrapSelfHostedSignupRequest) *model.BootstrapSelfHostedSignupResponse); ok { + r0 = rf(req) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.BootstrapSelfHostedSignupResponse) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(model.BootstrapSelfHostedSignupRequest) error); ok { + r1 = rf(req) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // ChangeSubscription provides a mock function with given fields: userID, subscriptionID, subscriptionChange func (_m *CloudInterface) ChangeSubscription(userID string, subscriptionID string, subscriptionChange *model.SubscriptionChange) (*model.Subscription, error) { ret := _m.Called(userID, subscriptionID, subscriptionChange) diff --git a/model/client4.go b/model/client4.go index 1057d0c55b..6d12fbd916 100644 --- a/model/client4.go +++ b/model/client4.go @@ -326,6 +326,10 @@ func (c *Client4) cloudRoute() string { return "/cloud" } +func (c *Client4) hostedCustomerRoute() string { + return "/hosted_customer" +} + func (c *Client4) testEmailRoute() string { return "/email/test" } @@ -8220,6 +8224,23 @@ func (c *Client4) UpdateCloudCustomerAddress(address *Address) (*CloudCustomer, return customer, BuildResponse(r), nil } +func (c *Client4) BootstrapSelfHostedSignup(req BootstrapSelfHostedSignupRequest) (*BootstrapSelfHostedSignupResponse, *Response, error) { + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, nil, NewAppError("BootstrapSelfHostedSignup", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + r, err := c.DoAPIPostBytes(c.hostedCustomerRoute()+"/bootstrap", reqBytes) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var res *BootstrapSelfHostedSignupResponse + json.NewDecoder(r.Body).Decode(&res) + + return res, BuildResponse(r), nil +} + func (c *Client4) ListImports() ([]string, *Response, error) { r, err := c.DoAPIGet(c.importsRoute(), "") if err != nil { diff --git a/model/cloud.go b/model/cloud.go index bbc3c8f32f..2985c929ea 100644 --- a/model/cloud.go +++ b/model/cloud.go @@ -274,6 +274,19 @@ 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"` +} + func (p *Product) IsYearly() bool { return p.RecurringInterval == RecurringIntervalYearly } diff --git a/model/config.go b/model/config.go index dbb39a987c..058a7b7666 100644 --- a/model/config.go +++ b/model/config.go @@ -383,6 +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"` AllowSyncedDrafts *bool `access:"site_posts"` } @@ -852,6 +853,10 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) { if s.AllowSyncedDrafts == nil { s.AllowSyncedDrafts = NewBool(true) } + + if s.SelfHostedFirstTimePurchase == nil { + s.SelfHostedFirstTimePurchase = NewBool(false) + } } type ClusterSettings struct { diff --git a/model/license.go b/model/license.go index 43a4c6af69..d04a88acef 100644 --- a/model/license.go +++ b/model/license.go @@ -56,6 +56,7 @@ type License struct { SkuShortName string `json:"sku_short_name"` IsTrial bool `json:"is_trial"` IsGovSku bool `json:"is_gov_sku"` + SignupJWT *string `json:"signup_jwt"` } type Customer struct { diff --git a/model/license_test.go b/model/license_test.go index 62bba9439c..6319ccc8e9 100644 --- a/model/license_test.go +++ b/model/license_test.go @@ -158,6 +158,11 @@ func TestIsCloud(t *testing.T) { l1.Features = nil assert.False(t, l1.IsCloud()) + + t.Run("false if license is nil", func(t *testing.T) { + var license *License + assert.False(t, license.IsCloud()) + }) } func TestLicenseRecordIsValid(t *testing.T) { diff --git a/services/telemetry/telemetry.go b/services/telemetry/telemetry.go index c72759b128..ee8fea6f11 100644 --- a/services/telemetry/telemetry.go +++ b/services/telemetry/telemetry.go @@ -450,6 +450,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, "allow_synced_drafts": *cfg.ServiceSettings.AllowSyncedDrafts, }) diff --git a/web/handlers.go b/web/handlers.go index bc836b72ba..798831bc73 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() { + if c.App.Channels().License().IsCloud() || *c.App.Config().ServiceSettings.SelfHostedFirstTimePurchase { cloudCSP = " js.stripe.com/v3" }