From a8fa3f29e98246dff03f87c88699afccd6628d22 Mon Sep 17 00:00:00 2001 From: Nathaniel Allred Date: Tue, 13 Dec 2022 13:36:18 -0600 Subject: [PATCH] Self-hosted in-product purchase (#21804) * Self-hosted admins can purchase licenses in-app when `ServiceSettings,SelfHostedPurchase` is true (the default) * Content Security Policy enables loading assets from `js.stripe.com/v3` when `ServiceSettings.SelfHostedPurchase` is true (the default). * Add `hosted_customer` API subpath * Add status of SelfHostedPurchase to telemetry config report. * Support showing admins self-hosted invoices when `ServiceSettings.SelfHostedPurchase` is true (the default) Co-authored-by: Conor Macpherson <116016004+ConorMacpherson@users.noreply.github.com> --- api4/hosted_customer.go | 213 ++++++++++++++++++++++++++- api4/hosted_customer_test.go | 8 +- app/app_iface.go | 1 + app/hosted_customer.go | 21 +++ app/opentracing/opentracing_layer.go | 15 ++ einterfaces/cloud.go | 8 + einterfaces/mocks/CloudInterface.go | 127 ++++++++++++++++ i18n/en.json | 4 + model/client4.go | 66 +++++++++ model/cloud.go | 19 +-- model/config.go | 6 +- model/hosted_customer.go | 58 ++++++++ model/websocket_message.go | 1 + services/telemetry/telemetry.go | 2 +- web/handlers.go | 2 +- web/handlers_test.go | 29 +++- 16 files changed, 551 insertions(+), 29 deletions(-) create mode 100644 app/hosted_customer.go create mode 100644 model/hosted_customer.go 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/app/app_iface.go b/app/app_iface.go index 0465d8f916..44ea3d936a 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -916,6 +916,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 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/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 4e2f0fb783..1cea08e051 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -12632,6 +12632,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") 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/en.json b/i18n/en.json index d1399190f6..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" diff --git a/model/client4.go b/model/client4.go index 511bc44a3f..d52df99214 100644 --- a/model/client4.go +++ b/model/client4.go @@ -8558,6 +8558,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 bdbc39bfe7..7feae13e3d 100644 --- a/model/cloud.go +++ b/model/cloud.go @@ -290,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/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/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/services/telemetry/telemetry.go b/services/telemetry/telemetry.go index 9db3a36d00..71820f0d97 100644 --- a/services/telemetry/telemetry.go +++ b/services/telemetry/telemetry.go @@ -455,7 +455,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, }) 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"]) }) }