From 50e37068b5f50d365c39bb2508a59aed10a77912 Mon Sep 17 00:00:00 2001 From: Maria A Nunez Date: Mon, 28 Sep 2020 12:18:47 -0400 Subject: [PATCH] MM-27147 - `/cloud` api endpoints (#15626) --- api4/api.go | 5 ++ api4/cloud.go | 117 +++++++++++++++++++++++++++ app/app.go | 3 + app/app_iface.go | 1 + app/enterprise.go | 9 +++ app/opentracing/opentracing_layer.go | 17 ++++ app/server.go | 1 + einterfaces/cloud.go | 15 ++++ i18n/en.json | 24 ++++++ model/client4.go | 44 ++++++++++ model/cloud.go | 32 ++++++++ model/config.go | 14 ++++ utils/subpath.go | 4 +- utils/subpath_test.go | 10 +-- web/handlers.go | 8 +- 15 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 api4/cloud.go create mode 100644 einterfaces/cloud.go create mode 100644 model/cloud.go diff --git a/api4/api.go b/api4/api.go index 8a225b3f4f4..961f5fb3e30 100644 --- a/api4/api.go +++ b/api4/api.go @@ -119,6 +119,8 @@ type Routes struct { TermsOfService *mux.Router // 'api/v4/terms_of_service' Groups *mux.Router // 'api/v4/groups' + + Cloud *mux.Router // 'api/v4/cloud' } type API struct { @@ -227,6 +229,8 @@ func Init(configservice configservice.ConfigService, globalOptionsFunc app.AppOp api.BaseRoutes.TermsOfService = api.BaseRoutes.ApiRoot.PathPrefix("/terms_of_service").Subrouter() api.BaseRoutes.Groups = api.BaseRoutes.ApiRoot.PathPrefix("/groups").Subrouter() + api.BaseRoutes.Cloud = api.BaseRoutes.ApiRoot.PathPrefix("/cloud").Subrouter() + api.InitUser() api.InitBot() api.InitTeam() @@ -262,6 +266,7 @@ func Init(configservice configservice.ConfigService, globalOptionsFunc app.AppOp api.InitTermsOfService() api.InitGroup() api.InitAction() + api.InitCloud() root.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404)) diff --git a/api4/cloud.go b/api4/cloud.go new file mode 100644 index 00000000000..dba507bd44a --- /dev/null +++ b/api4/cloud.go @@ -0,0 +1,117 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "io/ioutil" + "net/http" + + "github.com/mattermost/mattermost-server/v5/audit" + "github.com/mattermost/mattermost-server/v5/model" +) + +func (api *API) InitCloud() { + // GET /api/v4/cloud/products + api.BaseRoutes.Cloud.Handle("/products", api.ApiSessionRequired(getCloudProducts)).Methods("GET") + + // POST /api/v4/cloud/payment + // POST /api/v4/cloud/payment/confirm + api.BaseRoutes.Cloud.Handle("/payment", api.ApiSessionRequired(createCustomerPayment)).Methods("POST") + api.BaseRoutes.Cloud.Handle("/payment/confirm", api.ApiSessionRequired(confirmCustomerPayment)).Methods("POST") +} + +func getCloudProducts(c *Context, w http.ResponseWriter, r *http.Request) { + if c.App.Srv().License() == nil || !*c.App.Srv().License().Features.Cloud { + c.Err = model.NewAppError("Api4.getCloudProducts", "api.cloud.license_error", nil, "", http.StatusNotImplemented) + return + } + + if !c.App.SessionHasPermissionTo(*c.App.Session(), model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + products, appErr := c.App.Cloud().GetCloudProducts() + if appErr != nil { + c.Err = model.NewAppError("Api4.getCloudProducts", "api.cloud.request_error", nil, appErr.Error(), http.StatusInternalServerError) + return + } + + json, err := json.Marshal(products) + if err != nil { + c.Err = model.NewAppError("Api4.getCloudProducts", "api.cloud.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + w.Write(json) +} + +func createCustomerPayment(c *Context, w http.ResponseWriter, r *http.Request) { + if c.App.Srv().License() == nil || !*c.App.Srv().License().Features.Cloud { + c.Err = model.NewAppError("Api4.createCustomerPayment", "api.cloud.license_error", nil, "", http.StatusNotImplemented) + return + } + + if !c.App.SessionHasPermissionTo(*c.App.Session(), model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + auditRec := c.MakeAuditRecord("createCustomerPayment", audit.Fail) + defer c.LogAuditRec(auditRec) + + intent, appErr := c.App.Cloud().CreateCustomerPayment() + if appErr != nil { + c.Err = model.NewAppError("Api4.createCustomerPayment", "api.cloud.request_error", nil, appErr.Error(), http.StatusInternalServerError) + return + } + + json, err := json.Marshal(intent) + if err != nil { + c.Err = model.NewAppError("Api4.createCustomerPayment", "api.cloud.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + auditRec.Success() + + w.Write(json) +} + +func confirmCustomerPayment(c *Context, w http.ResponseWriter, r *http.Request) { + if c.App.Srv().License() == nil || !*c.App.Srv().License().Features.Cloud { + c.Err = model.NewAppError("Api4.confirmCustomerPayment", "api.cloud.license_error", nil, "", http.StatusNotImplemented) + return + } + + if !c.App.SessionHasPermissionTo(*c.App.Session(), model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + auditRec := c.MakeAuditRecord("confirmCustomerPayment", audit.Fail) + defer c.LogAuditRec(auditRec) + + bodyBytes, err := ioutil.ReadAll(r.Body) + if err != nil { + c.Err = model.NewAppError("Api4.confirmCustomerPayment", "api.cloud.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + var confirmRequest *model.ConfirmPaymentMethodRequest + if err = json.Unmarshal(bodyBytes, &confirmRequest); err != nil { + c.Err = model.NewAppError("Api4.confirmCustomerPayment", "api.cloud.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + appErr := c.App.Cloud().ConfirmCustomerPayment(confirmRequest) + if appErr != nil { + c.Err = model.NewAppError("Api4.createCustomerPayment", "api.cloud.request_error", nil, appErr.Error(), http.StatusInternalServerError) + return + } + + auditRec.Success() + + ReturnStatusOK(w) +} diff --git a/app/app.go b/app/app.go index 68643dce364..90602570dd5 100644 --- a/app/app.go +++ b/app/app.go @@ -630,6 +630,9 @@ func (a *App) Notification() einterfaces.NotificationInterface { func (a *App) Saml() einterfaces.SamlInterface { return a.srv.Saml } +func (a *App) Cloud() einterfaces.CloudInterface { + return a.srv.Cloud +} func (a *App) HTTPService() httpservice.HTTPService { return a.srv.HTTPService } diff --git a/app/app_iface.go b/app/app_iface.go index c825f3f8a53..97ae79e4b95 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -397,6 +397,7 @@ type AppIface interface { ClearTeamMembersCache(teamID string) ClientConfig() map[string]string ClientConfigHash() string + Cloud() einterfaces.CloudInterface Cluster() einterfaces.ClusterInterface CompareAndDeletePluginKey(pluginId string, key string, oldValue []byte) (bool, *model.AppError) CompareAndSetPluginKey(pluginId string, key string, oldValue, newValue []byte) (bool, *model.AppError) diff --git a/app/enterprise.go b/app/enterprise.go index 4f77c2cc2b2..f43a7b4cf14 100644 --- a/app/enterprise.go +++ b/app/enterprise.go @@ -120,6 +120,12 @@ func RegisterMessageExportInterface(f func(*Server) einterfaces.MessageExportInt messageExportInterface = f } +var cloudInterface func(*App) einterfaces.CloudInterface + +func RegisterCloudInterface(f func(*App) einterfaces.CloudInterface) { + cloudInterface = f +} + var metricsInterface func(*Server) einterfaces.MetricsInterface func RegisterMetricsInterface(f func(*Server) einterfaces.MetricsInterface) { @@ -195,4 +201,7 @@ func (a *App) initEnterprise() { } }) } + if cloudInterface != nil { + a.srv.Cloud = cloudInterface(a) + } } diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index a455f7c9aa9..0ccde03b147 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -1322,6 +1322,23 @@ func (a *OpenTracingAppLayer) ClientConfigWithComputed() map[string]string { return resultVar0 } +func (a *OpenTracingAppLayer) Cloud() einterfaces.CloudInterface { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.Cloud") + + 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.Cloud() + + return resultVar0 +} + func (a *OpenTracingAppLayer) CompareAndDeletePluginKey(pluginId string, key string, oldValue []byte) (bool, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CompareAndDeletePluginKey") diff --git a/app/server.go b/app/server.go index b472b92bcd3..6d1b4277f72 100644 --- a/app/server.go +++ b/app/server.go @@ -163,6 +163,7 @@ type Server struct { DataRetention einterfaces.DataRetentionInterface Ldap einterfaces.LdapInterface MessageExport einterfaces.MessageExportInterface + Cloud einterfaces.CloudInterface Metrics einterfaces.MetricsInterface Notification einterfaces.NotificationInterface Saml einterfaces.SamlInterface diff --git a/einterfaces/cloud.go b/einterfaces/cloud.go new file mode 100644 index 00000000000..33f720e14bf --- /dev/null +++ b/einterfaces/cloud.go @@ -0,0 +1,15 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package einterfaces + +import ( + "github.com/mattermost/mattermost-server/v5/model" +) + +type CloudInterface interface { + GetCloudProducts() ([]*model.Product, *model.AppError) + + CreateCustomerPayment() (*model.StripeSetupIntent, *model.AppError) + ConfirmCustomerPayment(*model.ConfirmPaymentMethodRequest) *model.AppError +} diff --git a/i18n/en.json b/i18n/en.json index f5e9ce52b5f..b475c409b01 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -471,6 +471,18 @@ "id": "api.channel.update_team_member_roles.scheme_role.app_error", "translation": "The provided role is managed by a Scheme and therefore cannot be applied directly to a Team Member." }, + { + "id": "api.cloud.app_error", + "translation": "Internal error during cloud api request." + }, + { + "id": "api.cloud.license_error", + "translation": "Your license does not support cloud requests." + }, + { + "id": "api.cloud.request_error", + "translation": "Error processing request to CWS." + }, { "id": "api.command.admin_only.app_error", "translation": "Integrations have been limited to admins only." @@ -5570,6 +5582,18 @@ "id": "ent.api.post.send_notifications_and_forget.push_image_only", "translation": " attached a file." }, + { + "id": "ent.cloud.authentication_failed", + "translation": "Unable to authenticate to CWS" + }, + { + "id": "ent.cloud.json_encode.error", + "translation": "Internal error marshaling request to CWS" + }, + { + "id": "ent.cloud.request_error", + "translation": "Error processing request to CWS" + }, { "id": "ent.cluster.404.app_error", "translation": "Cluster API endpoint not found." diff --git a/model/client4.go b/model/client4.go index 832f12d9af9..71a5e490824 100644 --- a/model/client4.go +++ b/model/client4.go @@ -334,6 +334,10 @@ func (c *Client4) GetSystemRoute() string { return "/system" } +func (c *Client4) GetCloudRoute() string { + return "/cloud" +} + func (c *Client4) GetTestEmailRoute() string { return "/email/test" } @@ -5606,3 +5610,43 @@ func (c *Client4) UploadData(uploadId string, data io.Reader) (*FileInfo, *Respo defer closeBody(r) return FileInfoFromJson(r.Body), BuildResponse(r) } + +// Cloud Section + +func (c *Client4) GetCloudProducts() ([]*Product, *Response) { + r, appErr := c.DoApiGet(c.GetCloudRoute()+"/products", "") + if appErr != nil { + return nil, BuildErrorResponse(r, appErr) + } + defer closeBody(r) + + var cloudProducts []*Product + json.NewDecoder(r.Body).Decode(&cloudProducts) + + return cloudProducts, BuildResponse(r) +} + +func (c *Client4) CreateCustomerPayment() (*StripeSetupIntent, *Response) { + r, appErr := c.DoApiPost(c.GetCloudRoute()+"/payment", "") + if appErr != nil { + return nil, BuildErrorResponse(r, appErr) + } + defer closeBody(r) + + var setupIntent *StripeSetupIntent + json.NewDecoder(r.Body).Decode(&setupIntent) + + return setupIntent, BuildResponse(r) +} + +func (c *Client4) ConfirmCustomerPayment(confirmRequest *ConfirmPaymentMethodRequest) *Response { + json, _ := json.Marshal(confirmRequest) + + r, appErr := c.doApiPostBytes(c.GetCloudRoute()+"/payment/confirm", json) + if appErr != nil { + return BuildErrorResponse(r, appErr) + } + defer closeBody(r) + + return BuildResponse(r) +} diff --git a/model/cloud.go b/model/cloud.go new file mode 100644 index 00000000000..93cd73b4f11 --- /dev/null +++ b/model/cloud.go @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +// Product model represents a product on the cloud system. +type Product struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + PricePerSeat float64 `json:"price_per_seat"` + AddOns []*AddOn `json:"add_ons"` +} + +// AddOn represents an addon to a product. +type AddOn struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + PricePerSeat float64 `json:"price_per_seat"` +} + +// StripeSetupIntent represents the SetupIntent model from Stripe for updating payment methods. +type StripeSetupIntent struct { + ID string `json:"id"` + ClientSecret string `json:"client_secret"` +} + +// ConfirmPaymentMethodRequest contains the fields for the customer payment update API. +type ConfirmPaymentMethodRequest struct { + StripeSetupIntentID string `json:"stripe_setup_intent_id"` +} diff --git a/model/config.go b/model/config.go index 2d7609f62f2..bc013d73358 100644 --- a/model/config.go +++ b/model/config.go @@ -218,6 +218,8 @@ const ( OFFICE365_SETTINGS_DEFAULT_TOKEN_ENDPOINT = "https://login.microsoftonline.com/common/oauth2/v2.0/token" OFFICE365_SETTINGS_DEFAULT_USER_API_ENDPOINT = "https://graph.microsoft.com/v1.0/me" + CLOUD_SETTINGS_DEFAULT_CWS_URL = "https://customers.mattermost.com" + LOCAL_MODE_SOCKET_PATH = "/var/tmp/mattermost_local.socket" ) @@ -2541,6 +2543,16 @@ func (s *JobSettings) SetDefaults() { } } +type CloudSettings struct { + CWSUrl *string `access:"environment,write_restrictable"` +} + +func (s *CloudSettings) SetDefaults() { + if s.CWSUrl == nil { + s.CWSUrl = NewString(CLOUD_SETTINGS_DEFAULT_CWS_URL) + } +} + type PluginState struct { Enable bool } @@ -2849,6 +2861,7 @@ type Config struct { DisplaySettings DisplaySettings GuestAccountsSettings GuestAccountsSettings ImageProxySettings ImageProxySettings + CloudSettings CloudSettings } func (o *Config) Clone() *Config { @@ -2935,6 +2948,7 @@ func (o *Config) SetDefaults() { o.DisplaySettings.SetDefaults() o.GuestAccountsSettings.SetDefaults() o.ImageProxySettings.SetDefaults(o.ServiceSettings) + o.CloudSettings.SetDefaults() } func (o *Config) IsValid() *AppError { diff --git a/utils/subpath.go b/utils/subpath.go index d294de2da52..aa30c0816d5 100644 --- a/utils/subpath.go +++ b/utils/subpath.go @@ -85,13 +85,13 @@ func UpdateAssetsSubpathInDir(subpath, directory string) error { newRootHtml := string(oldRootHtml) - reCSP := regexp.MustCompile(``) + reCSP := regexp.MustCompile(``) if results := reCSP.FindAllString(newRootHtml, -1); len(results) == 0 { return fmt.Errorf("failed to find 'Content-Security-Policy' meta tag to rewrite") } newRootHtml = reCSP.ReplaceAllLiteralString(newRootHtml, fmt.Sprintf( - ``, + ``, GetSubpathScriptHash(subpath), )) diff --git a/utils/subpath_test.go b/utils/subpath_test.go index bb5e1082cb5..e05b6d8a9be 100644 --- a/utils/subpath_test.go +++ b/utils/subpath_test.go @@ -268,19 +268,19 @@ func sToP(s string) *string { return &s } -const contentSecurityPolicyNotFoundHtml = ` Mattermost

Cannot connect to Mattermost


We're having trouble connecting to Mattermost. If refreshing this page (Ctrl+R or Command+R) does not work, please verify that your computer is connected to the internet.


` +const contentSecurityPolicyNotFoundHtml = ` Mattermost

Cannot connect to Mattermost


We're having trouble connecting to Mattermost. If refreshing this page (Ctrl+R or Command+R) does not work, please verify that your computer is connected to the internet.


` -const contentSecurityPolicyNotFound2Html = ` Mattermost

Cannot connect to Mattermost


We're having trouble connecting to Mattermost. If refreshing this page (Ctrl+R or Command+R) does not work, please verify that your computer is connected to the internet.


` +const contentSecurityPolicyNotFound2Html = ` Mattermost

Cannot connect to Mattermost


We're having trouble connecting to Mattermost. If refreshing this page (Ctrl+R or Command+R) does not work, please verify that your computer is connected to the internet.


` -const baseRootHtml = ` Mattermost

Cannot connect to Mattermost


We're having trouble connecting to Mattermost. If refreshing this page (Ctrl+R or Command+R) does not work, please verify that your computer is connected to the internet.


` +const baseRootHtml = ` Mattermost

Cannot connect to Mattermost


We're having trouble connecting to Mattermost. If refreshing this page (Ctrl+R or Command+R) does not work, please verify that your computer is connected to the internet.


` const baseCss = `@font-face{font-family:FontAwesome;src:url(/static/files/674f50d287a8c48dc19ba404d20fe713.eot);src:url(/static/files/674f50d287a8c48dc19ba404d20fe713.eot?#iefix&v=4.7.0) format("embedded-opentype"),url(/static/files/af7ae505a9eed503f8b8e6982036873e.woff2) format("woff2"),url(/static/files/fee66e712a8a08eef5805a46892932ad.woff) format("woff"),url(/static/files/b06871f281fee6b241d60582ae9369b9.ttf) format("truetype"),url(/static/files/677433a0892aaed7b7d2628c313c9775.svg#fontawesomeregular) format("svg");font-weight:400;font-style:normal}` -const subpathRootHtml = ` Mattermost

Cannot connect to Mattermost


We're having trouble connecting to Mattermost. If refreshing this page (Ctrl+R or Command+R) does not work, please verify that your computer is connected to the internet.


` +const subpathRootHtml = ` Mattermost

Cannot connect to Mattermost


We're having trouble connecting to Mattermost. If refreshing this page (Ctrl+R or Command+R) does not work, please verify that your computer is connected to the internet.


` const subpathCss = `@font-face{font-family:FontAwesome;src:url(/subpath/static/files/674f50d287a8c48dc19ba404d20fe713.eot);src:url(/subpath/static/files/674f50d287a8c48dc19ba404d20fe713.eot?#iefix&v=4.7.0) format("embedded-opentype"),url(/subpath/static/files/af7ae505a9eed503f8b8e6982036873e.woff2) format("woff2"),url(/subpath/static/files/fee66e712a8a08eef5805a46892932ad.woff) format("woff"),url(/subpath/static/files/b06871f281fee6b241d60582ae9369b9.ttf) format("truetype"),url(/subpath/static/files/677433a0892aaed7b7d2628c313c9775.svg#fontawesomeregular) format("svg");font-weight:400;font-style:normal}` -const newSubpathRootHtml = ` Mattermost

Cannot connect to Mattermost


We're having trouble connecting to Mattermost. If refreshing this page (Ctrl+R or Command+R) does not work, please verify that your computer is connected to the internet.


` +const newSubpathRootHtml = ` Mattermost

Cannot connect to Mattermost


We're having trouble connecting to Mattermost. If refreshing this page (Ctrl+R or Command+R) does not work, please verify that your computer is connected to the internet.


` const newSubpathCss = `@font-face{font-family:FontAwesome;src:url(/nested/subpath/static/files/674f50d287a8c48dc19ba404d20fe713.eot);src:url(/nested/subpath/static/files/674f50d287a8c48dc19ba404d20fe713.eot?#iefix&v=4.7.0) format("embedded-opentype"),url(/nested/subpath/static/files/af7ae505a9eed503f8b8e6982036873e.woff2) format("woff2"),url(/nested/subpath/static/files/fee66e712a8a08eef5805a46892932ad.woff) format("woff"),url(/nested/subpath/static/files/b06871f281fee6b241d60582ae9369b9.ttf) format("truetype"),url(/nested/subpath/static/files/677433a0892aaed7b7d2628c313c9775.svg#fontawesomeregular) format("svg");font-weight:400;font-style:normal}` diff --git a/web/handlers.go b/web/handlers.go index 49f40f27f13..e18a777eadf 100644 --- a/web/handlers.go +++ b/web/handlers.go @@ -162,12 +162,18 @@ func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Strict-Transport-Security", fmt.Sprintf("max-age=%d", *c.App.Config().ServiceSettings.TLSStrictTransportMaxAge)) } + cloudCSP := "" + if c.App.Srv().License() != nil && *c.App.Srv().License().Features.Cloud { + cloudCSP = " js.stripe.com/v3" + } + if h.IsStatic { // Instruct the browser not to display us in an iframe unless is the same origin for anti-clickjacking w.Header().Set("X-Frame-Options", "SAMEORIGIN") // Set content security policy. This is also specified in the root.html of the webapp in a meta tag. w.Header().Set("Content-Security-Policy", fmt.Sprintf( - "frame-ancestors 'self'; script-src 'self' cdn.rudderlabs.com%s", + "frame-ancestors 'self'; script-src 'self' cdn.rudderlabs.com%s%s", + cloudCSP, h.cspShaDirective, )) } else {