From 8d1a765fd611d6a2a4e879c91b33573922cbe0da Mon Sep 17 00:00:00 2001 From: Moby von Briesen Date: Thu, 7 Sep 2023 15:11:20 -0400 Subject: [PATCH] satellite/console: Partially revert change to remove graphql This partially reverts commit 516241e406923dedcc66df06b7e7c1479dc98b91. Endpoints are added to the backend, as there are some customers who may use these endpoints, even though they are no longer necessary for the satellite UI. Change-Id: I52a99912d9eacf269fbb2ddca603e53c4af6d6bf --- go.mod | 1 + go.sum | 2 + .../console/consoleweb/consoleql/apikey.go | 134 ++++ .../console/consoleweb/consoleql/keys.go | 23 + .../console/consoleweb/consoleql/mutation.go | 372 ++++++++++ .../consoleweb/consoleql/mutation_test.go | 511 ++++++++++++++ .../console/consoleweb/consoleql/project.go | 529 +++++++++++++++ .../consoleweb/consoleql/projectmember.go | 150 ++++ .../console/consoleweb/consoleql/query.go | 82 +++ .../consoleweb/consoleql/query_test.go | 501 ++++++++++++++ .../console/consoleweb/consoleql/reward.go | 64 ++ .../console/consoleweb/consoleql/schema.go | 27 + .../consoleweb/consoleql/testdata/Invite.html | 612 +++++++++++++++++ .../consoleql/testdata/Welcome.html | 640 ++++++++++++++++++ .../consoleweb/consoleql/typecreator.go | 168 +++++ .../console/consoleweb/consoleql/user.go | 78 +++ .../console/consoleweb/endpoints_test.go | 117 ++++ satellite/console/consoleweb/server.go | 139 +++- satellite/console/consoleweb/utils.go | 41 ++ testsuite/storjscan/go.mod | 1 + testsuite/storjscan/go.sum | 2 + testsuite/ui/go.mod | 1 + testsuite/ui/go.sum | 2 + 23 files changed, 4196 insertions(+), 1 deletion(-) create mode 100644 satellite/console/consoleweb/consoleql/apikey.go create mode 100644 satellite/console/consoleweb/consoleql/keys.go create mode 100644 satellite/console/consoleweb/consoleql/mutation.go create mode 100644 satellite/console/consoleweb/consoleql/mutation_test.go create mode 100644 satellite/console/consoleweb/consoleql/project.go create mode 100644 satellite/console/consoleweb/consoleql/projectmember.go create mode 100644 satellite/console/consoleweb/consoleql/query.go create mode 100644 satellite/console/consoleweb/consoleql/query_test.go create mode 100644 satellite/console/consoleweb/consoleql/reward.go create mode 100644 satellite/console/consoleweb/consoleql/schema.go create mode 100644 satellite/console/consoleweb/consoleql/testdata/Invite.html create mode 100644 satellite/console/consoleweb/consoleql/testdata/Welcome.html create mode 100644 satellite/console/consoleweb/consoleql/typecreator.go create mode 100644 satellite/console/consoleweb/consoleql/user.go diff --git a/go.mod b/go.mod index c4a830f0e..bc491c141 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/google/go-cmp v0.5.9 github.com/gorilla/mux v1.8.0 github.com/gorilla/schema v1.2.0 + github.com/graphql-go/graphql v0.7.9 github.com/jackc/pgerrcode v0.0.0-20201024163028-a0d42d470451 github.com/jackc/pgtype v1.14.0 github.com/jackc/pgx/v5 v5.3.1 diff --git a/go.sum b/go.sum index 5a602a8a0..3c3cb808c 100644 --- a/go.sum +++ b/go.sum @@ -229,6 +229,8 @@ github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/graphql-go/graphql v0.7.9 h1:5Va/Rt4l5g3YjwDnid3vFfn43faaQBq7rMcIZ0VnV34= +github.com/graphql-go/graphql v0.7.9/go.mod h1:k6yrAYQaSP59DC5UVxbgxESlmVyojThKdORUqGDGmrI= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= diff --git a/satellite/console/consoleweb/consoleql/apikey.go b/satellite/console/consoleweb/consoleql/apikey.go new file mode 100644 index 000000000..4ea4dc111 --- /dev/null +++ b/satellite/console/consoleweb/consoleql/apikey.go @@ -0,0 +1,134 @@ +// Copyright (C) 2019 Storj Labs, Inc. +// See LICENSE for copying information. + +package consoleql + +import ( + "github.com/graphql-go/graphql" + + "storj.io/storj/satellite/console" +) + +const ( + // APIKeyInfoType is graphql type name for api key. + APIKeyInfoType = "keyInfo" + // CreateAPIKeyType is graphql type name for createAPIKey struct + // which incapsulates the actual key and it's info. + CreateAPIKeyType = "graphqlCreateAPIKey" + // FieldKey is field name for the actual key in createAPIKey. + FieldKey = "key" +) + +// graphqlAPIKeyInfo creates satellite.APIKeyInfo graphql object. +func graphqlAPIKeyInfo() *graphql.Object { + return graphql.NewObject(graphql.ObjectConfig{ + Name: APIKeyInfoType, + Fields: graphql.Fields{ + FieldID: &graphql.Field{ + Type: graphql.String, + }, + FieldProjectID: &graphql.Field{ + Type: graphql.String, + }, + FieldName: &graphql.Field{ + Type: graphql.String, + }, + FieldCreatedAt: &graphql.Field{ + Type: graphql.DateTime, + }, + }, + }) +} + +// graphqlCreateAPIKey creates createAPIKey graphql object. +func graphqlCreateAPIKey(types *TypeCreator) *graphql.Object { + return graphql.NewObject(graphql.ObjectConfig{ + Name: CreateAPIKeyType, + Fields: graphql.Fields{ + FieldKey: &graphql.Field{ + Type: graphql.String, + }, + APIKeyInfoType: &graphql.Field{ + Type: types.apiKeyInfo, + }, + }, + }) +} + +func graphqlAPIKeysCursor() *graphql.InputObject { + return graphql.NewInputObject(graphql.InputObjectConfig{ + Name: APIKeysCursorInputType, + Fields: graphql.InputObjectConfigFieldMap{ + SearchArg: &graphql.InputObjectFieldConfig{ + Type: graphql.NewNonNull(graphql.String), + }, + LimitArg: &graphql.InputObjectFieldConfig{ + Type: graphql.NewNonNull(graphql.Int), + }, + PageArg: &graphql.InputObjectFieldConfig{ + Type: graphql.NewNonNull(graphql.Int), + }, + OrderArg: &graphql.InputObjectFieldConfig{ + Type: graphql.NewNonNull(graphql.Int), + }, + OrderDirectionArg: &graphql.InputObjectFieldConfig{ + Type: graphql.NewNonNull(graphql.Int), + }, + }, + }) +} + +func graphqlAPIKeysPage(types *TypeCreator) *graphql.Object { + return graphql.NewObject(graphql.ObjectConfig{ + Name: APIKeysPageType, + Fields: graphql.Fields{ + FieldAPIKeys: &graphql.Field{ + Type: graphql.NewList(types.apiKeyInfo), + }, + SearchArg: &graphql.Field{ + Type: graphql.String, + }, + LimitArg: &graphql.Field{ + Type: graphql.Int, + }, + OrderArg: &graphql.Field{ + Type: graphql.Int, + }, + OrderDirectionArg: &graphql.Field{ + Type: graphql.Int, + }, + OffsetArg: &graphql.Field{ + Type: graphql.Int, + }, + FieldPageCount: &graphql.Field{ + Type: graphql.Int, + }, + FieldCurrentPage: &graphql.Field{ + Type: graphql.Int, + }, + FieldTotalCount: &graphql.Field{ + Type: graphql.Int, + }, + }, + }) +} + +// createAPIKey holds macaroon.APIKey and console.APIKeyInfo. +type createAPIKey struct { + Key string + KeyInfo *console.APIKeyInfo +} + +type apiKeysPage struct { + APIKeys []console.APIKeyInfo + + Search string + Limit uint + Order int + OrderDirection int + Offset uint64 + + PageCount uint + CurrentPage uint + TotalCount uint64 +} diff --git a/satellite/console/consoleweb/consoleql/keys.go b/satellite/console/consoleweb/consoleql/keys.go new file mode 100644 index 000000000..2cfb31b0b --- /dev/null +++ b/satellite/console/consoleweb/consoleql/keys.go @@ -0,0 +1,23 @@ +// Copyright (C) 2022 Storj Labs, Inc. +// See LICENSE for copying information. + +package consoleql + +const ( + // ActivationPath is key for path which handles account activation. + ActivationPath = "activationPath" + // PasswordRecoveryPath is key for path which handles password recovery. + PasswordRecoveryPath = "passwordRecoveryPath" + // CancelPasswordRecoveryPath is key for path which handles let us know sequence. + CancelPasswordRecoveryPath = "cancelPasswordRecoveryPath" + // SignInPath is key for sign in server route. + SignInPath = "signInPath" + // LetUsKnowURL is key to store let us know URL. + LetUsKnowURL = "letUsKnowURL" + // ContactInfoURL is a key to store contact info URL. + ContactInfoURL = "contactInfoURL" + // TermsAndConditionsURL is a key to store terms and conditions URL. + TermsAndConditionsURL = "termsAndConditionsURL" + // SatelliteRegion is a key to store the satellite's region/name. + SatelliteRegion = "satelliteRegion" +) diff --git a/satellite/console/consoleweb/consoleql/mutation.go b/satellite/console/consoleweb/consoleql/mutation.go new file mode 100644 index 000000000..c954a5aee --- /dev/null +++ b/satellite/console/consoleweb/consoleql/mutation.go @@ -0,0 +1,372 @@ +// Copyright (C) 2019 Storj Labs, Inc. +// See LICENSE for copying information. + +package consoleql + +import ( + "github.com/graphql-go/graphql" + "github.com/zeebo/errs" + "go.uber.org/zap" + + "storj.io/common/uuid" + "storj.io/storj/private/post" + "storj.io/storj/satellite/console" + "storj.io/storj/satellite/mailservice" +) + +const ( + // Mutation is graphql request that modifies data. + Mutation = "mutation" + + // CreateProjectMutation is a mutation name for project creation. + CreateProjectMutation = "createProject" + // DeleteProjectMutation is a mutation name for project deletion. + DeleteProjectMutation = "deleteProject" + // UpdateProjectMutation is a mutation name for project name and description updating. + UpdateProjectMutation = "updateProject" + + // AddProjectMembersMutation is a mutation name for adding new project members. + AddProjectMembersMutation = "addProjectMembers" + // DeleteProjectMembersMutation is a mutation name for deleting project members. + DeleteProjectMembersMutation = "deleteProjectMembers" + + // CreateAPIKeyMutation is a mutation name for api key creation. + CreateAPIKeyMutation = "createAPIKey" + // DeleteAPIKeysMutation is a mutation name for api key deleting. + DeleteAPIKeysMutation = "deleteAPIKeys" + + // AddPaymentMethodMutation is mutation name for adding new payment method. + AddPaymentMethodMutation = "addPaymentMethod" + // DeletePaymentMethodMutation is mutation name for deleting payment method. + DeletePaymentMethodMutation = "deletePaymentMethod" + // SetDefaultPaymentMethodMutation is mutation name setting payment method as default payment method. + SetDefaultPaymentMethodMutation = "setDefaultPaymentMethod" + + // InputArg is argument name for all input types. + InputArg = "input" + // ProjectFields is a field name for project specific fields. + ProjectFields = "projectFields" + // ProjectLimits is a field name for project specific limits. + ProjectLimits = "projectLimits" + // FieldProjectID is field name for projectID. + FieldProjectID = "projectID" + // FieldNewPassword is a field name for new password. + FieldNewPassword = "newPassword" + // Secret is a field name for registration token for user creation during Vanguard release. + Secret = "secret" + // ReferrerUserID is a field name for passing referrer's user id. + ReferrerUserID = "referrerUserId" +) + +// rootMutation creates mutation for graphql populated by AccountsClient. +func rootMutation(log *zap.Logger, service *console.Service, mailService *mailservice.Service, types *TypeCreator) *graphql.Object { + return graphql.NewObject(graphql.ObjectConfig{ + Name: Mutation, + Fields: graphql.Fields{ + // creates project from input params + CreateProjectMutation: &graphql.Field{ + Type: types.project, + Args: graphql.FieldConfigArgument{ + InputArg: &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(types.projectInput), + }, + }, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + var projectInput = fromMapProjectInfo(p.Args[InputArg].(map[string]interface{})) + + project, err := service.CreateProject(p.Context, projectInput) + if err != nil { + return nil, err + } + + return project, nil + }, + }, + // deletes project by id, taken from input params + DeleteProjectMutation: &graphql.Field{ + Type: types.project, + Args: graphql.FieldConfigArgument{ + FieldID: &graphql.ArgumentConfig{ + Type: graphql.String, + DefaultValue: "", + }, + FieldPublicID: &graphql.ArgumentConfig{ + Type: graphql.String, + DefaultValue: "", + }, + }, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + return nil, console.ErrUnauthorized.New("not implemented") + }, + }, + // updates project name and description. + UpdateProjectMutation: &graphql.Field{ + Type: types.project, + Args: graphql.FieldConfigArgument{ + FieldID: &graphql.ArgumentConfig{ + Type: graphql.String, + DefaultValue: "", + }, + FieldPublicID: &graphql.ArgumentConfig{ + Type: graphql.String, + DefaultValue: "", + }, + ProjectFields: &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(types.projectInput), + }, + ProjectLimits: &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(types.projectLimit), + }, + }, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + var projectInput, err = fromMapProjectInfoProjectLimits(p.Args[ProjectFields].(map[string]interface{}), p.Args[ProjectLimits].(map[string]interface{})) + if err != nil { + return nil, err + } + + projectID, err := getProjectID(p) + if err != nil { + return nil, err + } + + project, err := service.UpdateProject(p.Context, projectID, projectInput) + if err != nil { + return nil, err + } + + return project, nil + }, + }, + // add user as member of given project + AddProjectMembersMutation: &graphql.Field{ + Type: types.project, + Args: graphql.FieldConfigArgument{ + FieldProjectID: &graphql.ArgumentConfig{ + Type: graphql.String, + DefaultValue: "", + }, + FieldPublicID: &graphql.ArgumentConfig{ + Type: graphql.String, + DefaultValue: "", + }, + FieldEmail: &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.NewList(graphql.String)), + }, + }, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + inviter, err := console.GetUser(p.Context) + if err != nil { + return nil, err + } + + emails, _ := p.Args[FieldEmail].([]interface{}) + + projectID, err := getProjectID(p) + if err != nil { + return nil, err + } + + var userEmails []string + for _, email := range emails { + userEmails = append(userEmails, email.(string)) + } + + project, err := service.GetProject(p.Context, projectID) + if err != nil { + return nil, err + } + + users, err := service.AddProjectMembers(p.Context, project.ID, userEmails) + if err != nil { + return nil, err + } + + rootObject := p.Info.RootValue.(map[string]interface{}) + origin := rootObject["origin"].(string) + signIn := origin + rootObject[SignInPath].(string) + + for _, user := range users { + userName := user.ShortName + if user.ShortName == "" { + userName = user.FullName + } + + satelliteRegion := rootObject[SatelliteRegion].(string) + + mailService.SendRenderedAsync( + p.Context, + []post.Address{{Address: user.Email, Name: userName}}, + &console.ExistingUserProjectInvitationEmail{ + InviterEmail: inviter.Email, + Region: satelliteRegion, + SignInLink: signIn, + }, + ) + } + + return project, nil + }, + }, + // delete user membership for given project + DeleteProjectMembersMutation: &graphql.Field{ + Type: types.project, + Args: graphql.FieldConfigArgument{ + FieldProjectID: &graphql.ArgumentConfig{ + Type: graphql.String, + DefaultValue: "", + }, + FieldPublicID: &graphql.ArgumentConfig{ + Type: graphql.String, + DefaultValue: "", + }, + FieldEmail: &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.NewList(graphql.String)), + }, + }, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + emails, _ := p.Args[FieldEmail].([]interface{}) + + projectID, err := getProjectID(p) + if err != nil { + return nil, err + } + + var userEmails []string + for _, email := range emails { + userEmails = append(userEmails, email.(string)) + } + + project, err := service.GetProject(p.Context, projectID) + if err != nil { + return nil, err + } + + err = service.DeleteProjectMembersAndInvitations(p.Context, project.ID, userEmails) + if err != nil { + return nil, err + } + + return project, nil + }, + }, + // creates new api key + CreateAPIKeyMutation: &graphql.Field{ + Type: types.createAPIKey, + Args: graphql.FieldConfigArgument{ + FieldProjectID: &graphql.ArgumentConfig{ + Type: graphql.String, + DefaultValue: "", + }, + FieldPublicID: &graphql.ArgumentConfig{ + Type: graphql.String, + DefaultValue: "", + }, + FieldName: &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.String), + }, + }, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + name, _ := p.Args[FieldName].(string) + + projectID, err := getProjectID(p) + if err != nil { + return nil, err + } + + info, key, err := service.CreateAPIKey(p.Context, projectID, name) + if err != nil { + return nil, err + } + + return createAPIKey{ + Key: key.Serialize(), + KeyInfo: info, + }, nil + }, + }, + // deletes api key + DeleteAPIKeysMutation: &graphql.Field{ + Type: graphql.NewList(types.apiKeyInfo), + Args: graphql.FieldConfigArgument{ + FieldID: &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.NewList(graphql.String)), + }, + }, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + paramKeysID, _ := p.Args[FieldID].([]interface{}) + + var keyIds []uuid.UUID + var keys []console.APIKeyInfo + for _, id := range paramKeysID { + keyID, err := uuid.FromString(id.(string)) + if err != nil { + return nil, err + } + + key, err := service.GetAPIKeyInfo(p.Context, keyID) + if err != nil { + return nil, err + } + + keyIds = append(keyIds, keyID) + keys = append(keys, *key) + } + + err := service.DeleteAPIKeys(p.Context, keyIds) + if err != nil { + return nil, err + } + + return keys, nil + }, + }, + AddPaymentMethodMutation: &graphql.Field{ + Type: graphql.Boolean, + Args: graphql.FieldConfigArgument{}, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + return nil, nil + }, + }, + DeletePaymentMethodMutation: &graphql.Field{ + Type: graphql.Boolean, + Args: graphql.FieldConfigArgument{}, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + return nil, nil + }, + }, + SetDefaultPaymentMethodMutation: &graphql.Field{ + Type: graphql.Boolean, + Args: graphql.FieldConfigArgument{}, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + return nil, nil + }, + }, + }, + }) +} + +func getProjectID(p graphql.ResolveParams) (projectID uuid.UUID, err error) { + inputID, _ := p.Args[FieldID].(string) + inputProjectID, _ := p.Args[FieldProjectID].(string) + inputPublicID, _ := p.Args[FieldPublicID].(string) + + if inputID != "" { + projectID, err = uuid.FromString(inputID) + if err != nil { + return uuid.UUID{}, err + } + } else if inputProjectID != "" { + projectID, err = uuid.FromString(inputProjectID) + if err != nil { + return uuid.UUID{}, err + } + } else if inputPublicID != "" { + projectID, err = uuid.FromString(inputPublicID) + if err != nil { + return uuid.UUID{}, err + } + } else { + return uuid.UUID{}, errs.New("Project ID was not provided.") + } + return projectID, nil +} diff --git a/satellite/console/consoleweb/consoleql/mutation_test.go b/satellite/console/consoleweb/consoleql/mutation_test.go new file mode 100644 index 000000000..7f433e708 --- /dev/null +++ b/satellite/console/consoleweb/consoleql/mutation_test.go @@ -0,0 +1,511 @@ +// Copyright (C) 2019 Storj Labs, Inc. +// See LICENSE for copying information. + +package consoleql_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/graphql-go/graphql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + + "storj.io/common/testcontext" + "storj.io/common/uuid" + "storj.io/storj/private/post" + "storj.io/storj/private/testplanet" + "storj.io/storj/private/testredis" + "storj.io/storj/satellite/accounting" + "storj.io/storj/satellite/accounting/live" + "storj.io/storj/satellite/analytics" + "storj.io/storj/satellite/console" + "storj.io/storj/satellite/console/consoleauth" + "storj.io/storj/satellite/console/consoleweb/consoleql" + "storj.io/storj/satellite/console/restkeys" + "storj.io/storj/satellite/mailservice" + "storj.io/storj/satellite/payments" + "storj.io/storj/satellite/payments/paymentsconfig" + "storj.io/storj/satellite/payments/stripe" +) + +// discardSender discard sending of an actual email. +type discardSender struct{} + +// SendEmail immediately returns with nil error. +func (*discardSender) SendEmail(ctx context.Context, msg *post.Message) error { + return nil +} + +// FromAddress returns empty post.Address. +func (*discardSender) FromAddress() post.Address { + return post.Address{} +} + +func TestGraphqlMutation(t *testing.T) { + testplanet.Run(t, testplanet.Config{SatelliteCount: 1}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { + sat := planet.Satellites[0] + db := sat.DB + log := zaptest.NewLogger(t) + + analyticsService := analytics.NewService(log, analytics.Config{}, "test-satellite") + + redis, err := testredis.Mini(ctx) + require.NoError(t, err) + defer ctx.Check(redis.Close) + + cache, err := live.OpenCache(ctx, log.Named("cache"), live.Config{StorageBackend: "redis://" + redis.Addr() + "?db=0"}) + require.NoError(t, err) + + projectLimitCache := accounting.NewProjectLimitCache(db.ProjectAccounting(), 0, 0, 0, accounting.ProjectLimitConfig{CacheCapacity: 100}) + + projectUsage := accounting.NewService(db.ProjectAccounting(), cache, projectLimitCache, *sat.Metabase.DB, 5*time.Minute, -10*time.Second) + + // TODO maybe switch this test to testplanet to avoid defining config and Stripe service + pc := paymentsconfig.Config{ + UsagePrice: paymentsconfig.ProjectUsagePrice{ + StorageTB: "10", + EgressTB: "45", + Segment: "0.0000022", + }, + } + + prices, err := pc.UsagePrice.ToModel() + require.NoError(t, err) + + priceOverrides, err := pc.UsagePriceOverrides.ToModels() + require.NoError(t, err) + + paymentsService, err := stripe.NewService( + log.Named("payments.stripe:service"), + stripe.NewStripeMock( + db.StripeCoinPayments().Customers(), + db.Console().Users(), + ), + pc.StripeCoinPayments, + db.StripeCoinPayments(), + db.Wallets(), + db.Billing(), + db.Console().Projects(), + db.Console().Users(), + db.ProjectAccounting(), + prices, + priceOverrides, + pc.PackagePlans.Packages, + pc.BonusRate, + nil, + ) + require.NoError(t, err) + + service, err := console.NewService( + log.Named("console"), + db.Console(), + restkeys.NewService(db.OIDC().OAuthTokens(), planet.Satellites[0].Config.RESTKeys), + db.ProjectAccounting(), + projectUsage, + sat.API.Buckets.Service, + paymentsService.Accounts(), + // TODO: do we need a payment deposit wallet here? + nil, + db.Billing(), + analyticsService, + consoleauth.NewService(consoleauth.Config{ + TokenExpirationTime: 24 * time.Hour, + }, &consoleauth.Hmac{Secret: []byte("my-suppa-secret-key")}), + nil, + "", + "", + console.Config{ + PasswordCost: console.TestPasswordCost, + DefaultProjectLimit: 5, + Session: console.SessionConfig{ + Duration: time.Hour, + }, + }, + ) + require.NoError(t, err) + + mailService, err := mailservice.New(log, &discardSender{}, "testdata") + require.NoError(t, err) + defer ctx.Check(mailService.Close) + + rootObject := make(map[string]interface{}) + rootObject["origin"] = "http://doesntmatter.com/" + rootObject[consoleql.ActivationPath] = "?activationToken=" + rootObject[consoleql.SignInPath] = "login" + rootObject[consoleql.LetUsKnowURL] = "letUsKnowURL" + rootObject[consoleql.ContactInfoURL] = "contactInfoURL" + rootObject[consoleql.TermsAndConditionsURL] = "termsAndConditionsURL" + rootObject[consoleql.SatelliteRegion] = "EU1" + + schema, err := consoleql.CreateSchema(log, service, mailService) + require.NoError(t, err) + + createUser := console.CreateUser{ + FullName: "John Roll", + ShortName: "Roll", + Email: "test@mail.test", + UserAgent: []byte("120bf202-8252-437e-ac12-0e364bee852e"), + Password: "123a123", + SignupPromoCode: "promo1", + } + + regToken, err := service.CreateRegToken(ctx, 1) + require.NoError(t, err) + + rootUser, err := service.CreateUser(ctx, createUser, regToken.Secret) + require.NoError(t, err) + require.Equal(t, createUser.UserAgent, rootUser.UserAgent) + + couponType, err := paymentsService.Accounts().Setup(ctx, rootUser.ID, rootUser.Email, rootUser.SignupPromoCode) + + var signupCouponType payments.CouponType = payments.SignupCoupon + + require.NoError(t, err) + assert.Equal(t, signupCouponType, couponType) + + activationToken, err := service.GenerateActivationToken(ctx, rootUser.ID, rootUser.Email) + require.NoError(t, err) + + _, err = service.ActivateAccount(ctx, activationToken) + require.NoError(t, err) + + tokenInfo, err := service.Token(ctx, console.AuthUser{Email: createUser.Email, Password: createUser.Password}) + require.NoError(t, err) + + userCtx, err := service.TokenAuth(ctx, tokenInfo.Token, time.Now()) + require.NoError(t, err) + + testQuery := func(t *testing.T, query string) (interface{}, error) { + result := graphql.Do(graphql.Params{ + Schema: schema, + Context: userCtx, + RequestString: query, + RootObject: rootObject, + }) + + for _, err := range result.Errors { + if err.OriginalError() != nil { + return nil, err + } + } + require.False(t, result.HasErrors()) + + return result.Data, nil + } + + tokenInfo, err = service.Token(ctx, console.AuthUser{Email: rootUser.Email, Password: createUser.Password}) + require.NoError(t, err) + + userCtx, err = service.TokenAuth(ctx, tokenInfo.Token, time.Now()) + require.NoError(t, err) + + var projectIDField string + var projectPublicIDField string + t.Run("Create project mutation", func(t *testing.T) { + projectInfo := console.UpsertProjectInfo{ + Name: "Project name", + Description: "desc", + } + + query := fmt.Sprintf( + "mutation {createProject(input:{name:\"%s\",description:\"%s\"}){name,description,id,publicId,createdAt}}", + projectInfo.Name, + projectInfo.Description, + ) + + result, err := testQuery(t, query) + require.NoError(t, err) + + data := result.(map[string]interface{}) + project := data[consoleql.CreateProjectMutation].(map[string]interface{}) + + assert.Equal(t, projectInfo.Name, project[consoleql.FieldName]) + assert.Equal(t, projectInfo.Description, project[consoleql.FieldDescription]) + + projectIDField = project[consoleql.FieldID].(string) + projectPublicIDField = project[consoleql.FieldPublicID].(string) + + _, err = uuid.FromString(projectPublicIDField) + require.NoError(t, err) + }) + + projectID, err := uuid.FromString(projectIDField) + require.NoError(t, err) + + project, err := service.GetProject(userCtx, projectID) + require.NoError(t, err) + require.Equal(t, rootUser.UserAgent, project.UserAgent) + + regTokenUser1, err := service.CreateRegToken(ctx, 1) + require.NoError(t, err) + + user1, err := service.CreateUser(userCtx, console.CreateUser{ + FullName: "User1", + Email: "u1@mail.test", + Password: "123a123", + }, regTokenUser1.Secret) + require.NoError(t, err) + + t.Run("Activation", func(t *testing.T) { + activationToken1, err := service.GenerateActivationToken( + ctx, + user1.ID, + "u1@mail.test", + ) + require.NoError(t, err) + + _, err = service.ActivateAccount(ctx, activationToken1) + require.NoError(t, err) + + user1.Email = "u1@mail.test" + }) + + regTokenUser2, err := service.CreateRegToken(ctx, 1) + require.NoError(t, err) + + user2, err := service.CreateUser(userCtx, console.CreateUser{ + FullName: "User1", + Email: "u2@mail.test", + Password: "123a123", + }, regTokenUser2.Secret) + require.NoError(t, err) + + t.Run("Activation", func(t *testing.T) { + activationToken2, err := service.GenerateActivationToken( + ctx, + user2.ID, + "u2@mail.test", + ) + require.NoError(t, err) + + _, err = service.ActivateAccount(ctx, activationToken2) + require.NoError(t, err) + + user2.Email = "u2@mail.test" + }) + + regTokenUser3, err := service.CreateRegToken(ctx, 1) + require.NoError(t, err) + + user3, err := service.CreateUser(userCtx, console.CreateUser{ + FullName: "User3", + Email: "u3@mail.test", + Password: "123a123", + }, regTokenUser3.Secret) + require.NoError(t, err) + + t.Run("Activation", func(t *testing.T) { + activationToken3, err := service.GenerateActivationToken( + ctx, + user3.ID, + "u3@mail.test", + ) + require.NoError(t, err) + + _, err = service.ActivateAccount(ctx, activationToken3) + require.NoError(t, err) + + user3.Email = "u3@mail.test" + }) + + testAdd := func(query string, expectedMembers int) { + result, err := testQuery(t, query) + require.NoError(t, err) + + data := result.(map[string]interface{}) + proj := data[consoleql.AddProjectMembersMutation].(map[string]interface{}) + + members := proj[consoleql.FieldMembersAndInvitations].(map[string]interface{}) + projectMembers := members[consoleql.FieldProjectMembers].([]interface{}) + + assert.Equal(t, project.ID.String(), proj[consoleql.FieldID]) + assert.Equal(t, project.PublicID.String(), proj[consoleql.FieldPublicID]) + assert.Equal(t, project.Name, proj[consoleql.FieldName]) + assert.Equal(t, expectedMembers, len(projectMembers)) + } + + t.Run("Add project members mutation", func(t *testing.T) { + query := fmt.Sprintf( + "mutation {addProjectMembers(projectID:\"%s\",email:[\"%s\",\"%s\"]){id,publicId,name,membersAndInvitations(cursor: { limit: 50, search: \"\", page: 1, order: 1, orderDirection: 2 }){projectMembers{joinedAt}}}}", + project.ID.String(), + user1.Email, + user2.Email, + ) + + testAdd(query, 3) + }) + + t.Run("Add project members mutation with publicId", func(t *testing.T) { + query := fmt.Sprintf( + "mutation {addProjectMembers(publicId:\"%s\",email:[\"%s\"]){id,publicId,name,membersAndInvitations(cursor: { limit: 50, search: \"\", page: 1, order: 1, orderDirection: 2 }){projectMembers{joinedAt}}}}", + project.PublicID.String(), + user3.Email, + ) + + testAdd(query, 4) + }) + + t.Run("Fail add project members mutation without ID", func(t *testing.T) { + query := fmt.Sprintf( + "mutation {addProjectMembers(email:[\"%s\",\"%s\"]){id,publicId,name,membersAndInvitations(cursor: { limit: 50, search: \"\", page: 1, order: 1, orderDirection: 2 }){projectMembers{joinedAt}}}}", + user1.Email, + user2.Email, + ) + + _, err = testQuery(t, query) + require.Error(t, err) + }) + + t.Run("Delete project members mutation", func(t *testing.T) { + query := fmt.Sprintf( + "mutation {deleteProjectMembers(projectID:\"%s\",email:[\"%s\",\"%s\",\"%s\"]){id,publicId,name,membersAndInvitations(cursor: { limit: 50, search: \"\", page: 1, order: 1, orderDirection: 2 }){projectMembers{user{id}}}}}", + project.ID.String(), + user1.Email, + user2.Email, + user3.Email, + ) + + result, err := testQuery(t, query) + require.NoError(t, err) + + data := result.(map[string]interface{}) + proj := data[consoleql.DeleteProjectMembersMutation].(map[string]interface{}) + + members := proj[consoleql.FieldMembersAndInvitations].(map[string]interface{}) + projectMembers := members[consoleql.FieldProjectMembers].([]interface{}) + rootMember := projectMembers[0].(map[string]interface{})[consoleql.UserType].(map[string]interface{}) + + assert.Equal(t, project.ID.String(), proj[consoleql.FieldID]) + assert.Equal(t, project.PublicID.String(), proj[consoleql.FieldPublicID]) + assert.Equal(t, project.Name, proj[consoleql.FieldName]) + assert.Equal(t, 1, len(members)) + + assert.Equal(t, rootUser.ID.String(), rootMember[consoleql.FieldID]) + }) + + var keyID string + t.Run("Create api key mutation", func(t *testing.T) { + keyName := "key1" + query := fmt.Sprintf( + "mutation {createAPIKey(projectID:\"%s\",name:\"%s\"){key,keyInfo{id,name,projectID}}}", + project.ID.String(), + keyName, + ) + + result, err := testQuery(t, query) + require.NoError(t, err) + + data := result.(map[string]interface{}) + createAPIKey := data[consoleql.CreateAPIKeyMutation].(map[string]interface{}) + + key := createAPIKey[consoleql.FieldKey].(string) + keyInfo := createAPIKey[consoleql.APIKeyInfoType].(map[string]interface{}) + + assert.NotEqual(t, "", key) + + assert.Equal(t, keyName, keyInfo[consoleql.FieldName]) + assert.Equal(t, project.ID.String(), keyInfo[consoleql.FieldProjectID]) + + keyID = keyInfo[consoleql.FieldID].(string) + }) + + t.Run("Delete api key mutation", func(t *testing.T) { + id, err := uuid.FromString(keyID) + require.NoError(t, err) + + info, err := service.GetAPIKeyInfo(userCtx, id) + require.NoError(t, err) + + query := fmt.Sprintf( + "mutation {deleteAPIKeys(id:[\"%s\"]){name,projectID}}", + keyID, + ) + + result, err := testQuery(t, query) + require.NoError(t, err) + + data := result.(map[string]interface{}) + keyInfoList := data[consoleql.DeleteAPIKeysMutation].([]interface{}) + + for _, k := range keyInfoList { + keyInfo := k.(map[string]interface{}) + + assert.Equal(t, info.Name, keyInfo[consoleql.FieldName]) + assert.Equal(t, project.ID.String(), keyInfo[consoleql.FieldProjectID]) + } + }) + + const testName = "testName" + const testDescription = "test description" + const StorageLimit = "100" + const BandwidthLimit = "100" + + testUpdate := func(query string) { + result, err := testQuery(t, query) + require.NoError(t, err) + + data := result.(map[string]interface{}) + proj := data[consoleql.UpdateProjectMutation].(map[string]interface{}) + + assert.Equal(t, project.ID.String(), proj[consoleql.FieldID]) + assert.Equal(t, project.PublicID.String(), proj[consoleql.FieldPublicID]) + assert.Equal(t, testName, proj[consoleql.FieldName]) + assert.Equal(t, testDescription, proj[consoleql.FieldDescription]) + } + + t.Run("Update project mutation", func(t *testing.T) { + query := fmt.Sprintf( + "mutation {updateProject(id:\"%s\",projectFields:{name:\"%s\",description:\"%s\"},projectLimits:{storageLimit:\"%s\",bandwidthLimit:\"%s\"}){id,publicId,name,description}}", + project.ID.String(), + testName, + testDescription, + StorageLimit, + BandwidthLimit, + ) + + testUpdate(query) + }) + + t.Run("Update project mutation with publicId", func(t *testing.T) { + query := fmt.Sprintf( + "mutation {updateProject(publicId:\"%s\",projectFields:{name:\"%s\",description:\"%s\"},projectLimits:{storageLimit:\"%s\",bandwidthLimit:\"%s\"}){id,publicId,name,description}}", + project.PublicID.String(), + testName, + testDescription, + StorageLimit, + BandwidthLimit, + ) + + testUpdate(query) + }) + + t.Run("Fail update project mutation without ID", func(t *testing.T) { + query := fmt.Sprintf( + "mutation {updateProject(projectFields:{name:\"%s\",description:\"%s\"},projectLimits:{storageLimit:\"%s\",bandwidthLimit:\"%s\"}){id,publicId,name,description}}", + testName, + testDescription, + StorageLimit, + BandwidthLimit, + ) + + _, err := testQuery(t, query) + require.Error(t, err) + }) + + t.Run("Delete project mutation", func(t *testing.T) { + query := fmt.Sprintf( + "mutation {deleteProject(id:\"%s\"){id,name}}", + projectID, + ) + + result, err := testQuery(t, query) + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, console.ErrUnauthorized.New("not implemented").Error(), err.Error()) + }) + }) +} diff --git a/satellite/console/consoleweb/consoleql/project.go b/satellite/console/consoleweb/consoleql/project.go new file mode 100644 index 000000000..3776a7190 --- /dev/null +++ b/satellite/console/consoleweb/consoleql/project.go @@ -0,0 +1,529 @@ +// Copyright (C) 2019 Storj Labs, Inc. +// See LICENSE for copying information. + +package consoleql + +import ( + "strconv" + "time" + + "github.com/graphql-go/graphql" + + "storj.io/common/memory" + "storj.io/storj/satellite/accounting" + "storj.io/storj/satellite/console" +) + +const ( + // ProjectType is a graphql type name for project. + ProjectType = "project" + // ProjectInputType is a graphql type name for project input. + ProjectInputType = "projectInput" + // ProjectLimitType is a graphql type name for project limit. + ProjectLimitType = "projectLimit" + // ProjectUsageType is a graphql type name for project usage. + ProjectUsageType = "projectUsage" + // ProjectsCursorInputType is a graphql input type name for projects cursor. + ProjectsCursorInputType = "projectsCursor" + // ProjectsPageType is a graphql type name for projects page. + ProjectsPageType = "projectsPage" + // BucketUsageCursorInputType is a graphql input + // type name for bucket usage cursor. + BucketUsageCursorInputType = "bucketUsageCursor" + // BucketUsageType is a graphql type name for bucket usage. + BucketUsageType = "bucketUsage" + // BucketUsagePageType is a graphql type name for bucket usage page. + BucketUsagePageType = "bucketUsagePage" + // ProjectMembersAndInvitationsPageType is a graphql type name for a page of project members and invitations. + ProjectMembersAndInvitationsPageType = "projectMembersAndInvitationsPage" + // ProjectMembersCursorInputType is a graphql type name for project members. + ProjectMembersCursorInputType = "projectMembersCursor" + // APIKeysPageType is a graphql type name for api keys page. + APIKeysPageType = "apiKeysPage" + // APIKeysCursorInputType is a graphql type name for api keys. + APIKeysCursorInputType = "apiKeysCursor" + // FieldPublicID is a field name for "publicId". + FieldPublicID = "publicId" + // FieldOwnerID is a field name for "ownerId". + FieldOwnerID = "ownerId" + // FieldName is a field name for "name". + FieldName = "name" + // FieldBucketName is a field name for "bucket name". + FieldBucketName = "bucketName" + // FieldDescription is a field name for description. + FieldDescription = "description" + // FieldMembersAndInvitations is field name for members and invitations. + FieldMembersAndInvitations = "membersAndInvitations" + // FieldAPIKeys is a field name for api keys. + FieldAPIKeys = "apiKeys" + // FieldUsage is a field name for usage rollup. + FieldUsage = "usage" + // FieldBucketUsages is a field name for bucket usages. + FieldBucketUsages = "bucketUsages" + // FieldStorageLimit is a field name for the storage limit. + FieldStorageLimit = "storageLimit" + // FieldBandwidthLimit is a field name for bandwidth limit. + FieldBandwidthLimit = "bandwidthLimit" + // FieldStorage is a field name for storage total. + FieldStorage = "storage" + // FieldEgress is a field name for egress total. + FieldEgress = "egress" + // FieldSegmentCount is a field name for segments count. + FieldSegmentCount = "segmentCount" + // FieldObjectCount is a field name for objects count. + FieldObjectCount = "objectCount" + // FieldPageCount is a field name for total page count. + FieldPageCount = "pageCount" + // FieldCurrentPage is a field name for current page number. + FieldCurrentPage = "currentPage" + // FieldTotalCount is a field name for bucket usage count total. + FieldTotalCount = "totalCount" + // FieldMemberCount is a field name for number of project members. + FieldMemberCount = "memberCount" + // FieldProjects is a field name for projects. + FieldProjects = "projects" + // FieldProjectMembers is a field name for project members. + FieldProjectMembers = "projectMembers" + // FieldProjectInvitations is a field name for project member invitations. + FieldProjectInvitations = "projectInvitations" + // CursorArg is an argument name for cursor. + CursorArg = "cursor" + // PageArg ia an argument name for page number. + PageArg = "page" + // LimitArg is argument name for limit. + LimitArg = "limit" + // OffsetArg is argument name for offset. + OffsetArg = "offset" + // SearchArg is argument name for search. + SearchArg = "search" + // OrderArg is argument name for order. + OrderArg = "order" + // OrderDirectionArg is argument name for order direction. + OrderDirectionArg = "orderDirection" + // SinceArg marks start of the period. + SinceArg = "since" + // BeforeArg marks end of the period. + BeforeArg = "before" +) + +// graphqlProject creates *graphql.Object type representation of satellite.ProjectInfo. +func graphqlProject(service *console.Service, types *TypeCreator) *graphql.Object { + return graphql.NewObject(graphql.ObjectConfig{ + Name: ProjectType, + Fields: graphql.Fields{ + FieldID: &graphql.Field{ + Type: graphql.String, + }, + FieldPublicID: &graphql.Field{ + Type: graphql.String, + }, + FieldName: &graphql.Field{ + Type: graphql.String, + }, + FieldOwnerID: &graphql.Field{ + Type: graphql.String, + }, + FieldDescription: &graphql.Field{ + Type: graphql.String, + }, + FieldCreatedAt: &graphql.Field{ + Type: graphql.DateTime, + }, + FieldMemberCount: &graphql.Field{ + Type: graphql.Int, + }, + FieldMembersAndInvitations: &graphql.Field{ + Type: types.projectMembersAndInvitationsPage, + Args: graphql.FieldConfigArgument{ + CursorArg: &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(types.projectMembersCursor), + }, + }, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + project, _ := p.Source.(*console.Project) + + _, err := console.GetUser(p.Context) + if err != nil { + return nil, err + } + + cursor := cursorArgsToProjectMembersCursor(p.Args[CursorArg].(map[string]interface{})) + page, err := service.GetProjectMembersAndInvitations(p.Context, project.ID, cursor) + if err != nil { + return nil, err + } + + var users []projectMember + for _, member := range page.ProjectMembers { + user, err := service.GetUser(p.Context, member.MemberID) + if err != nil { + return nil, err + } + + users = append(users, projectMember{ + User: user, + JoinedAt: member.CreatedAt, + }) + } + + var invites []projectInvitation + for _, invite := range page.ProjectInvitations { + invites = append(invites, projectInvitation{ + Email: invite.Email, + CreatedAt: invite.CreatedAt, + Expired: service.IsProjectInvitationExpired(&invite), + }) + } + + projectMembersPage := projectMembersPage{ + ProjectMembers: users, + ProjectInvitations: invites, + TotalCount: page.TotalCount, + Offset: page.Offset, + Limit: page.Limit, + Order: int(page.Order), + OrderDirection: int(page.OrderDirection), + Search: page.Search, + CurrentPage: page.CurrentPage, + PageCount: page.PageCount, + } + return projectMembersPage, nil + }, + }, + FieldAPIKeys: &graphql.Field{ + Type: types.apiKeyPage, + Args: graphql.FieldConfigArgument{ + CursorArg: &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(types.apiKeysCursor), + }, + }, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + project, _ := p.Source.(*console.Project) + + cursor := cursorArgsToAPIKeysCursor(p.Args[CursorArg].(map[string]interface{})) + page, err := service.GetAPIKeys(p.Context, project.ID, cursor) + if err != nil { + return nil, err + } + + apiKeysPage := apiKeysPage{ + APIKeys: page.APIKeys, + TotalCount: page.TotalCount, + Offset: page.Offset, + Limit: page.Limit, + Order: int(page.Order), + OrderDirection: int(page.OrderDirection), + Search: page.Search, + CurrentPage: page.CurrentPage, + PageCount: page.PageCount, + } + + return apiKeysPage, err + }, + }, + FieldUsage: &graphql.Field{ + Type: types.projectUsage, + Args: graphql.FieldConfigArgument{ + SinceArg: &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.DateTime), + }, + BeforeArg: &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.DateTime), + }, + }, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + project, _ := p.Source.(*console.Project) + + since := p.Args[SinceArg].(time.Time) + before := p.Args[BeforeArg].(time.Time) + + usage, err := service.GetProjectUsage(p.Context, project.ID, since, before) + if err != nil { + return nil, err + } + + return usage, nil + }, + }, + FieldBucketUsages: &graphql.Field{ + Type: types.bucketUsagePage, + Args: graphql.FieldConfigArgument{ + BeforeArg: &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(graphql.DateTime), + }, + CursorArg: &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(types.bucketUsageCursor), + }, + }, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + project, _ := p.Source.(*console.Project) + + before := p.Args[BeforeArg].(time.Time) + cursor := fromMapBucketUsageCursor(p.Args[CursorArg].(map[string]interface{})) + + page, err := service.GetBucketTotals(p.Context, project.ID, cursor, before) + if err != nil { + return nil, err + } + + return page, nil + }, + }, + }, + }) +} + +// graphqlProjectInput creates graphql.InputObject type needed to create/update satellite.Project. +func graphqlProjectInput() *graphql.InputObject { + return graphql.NewInputObject(graphql.InputObjectConfig{ + Name: ProjectInputType, + Fields: graphql.InputObjectConfigFieldMap{ + FieldName: &graphql.InputObjectFieldConfig{ + Type: graphql.String, + }, + FieldDescription: &graphql.InputObjectFieldConfig{ + Type: graphql.String, + }, + }, + }) +} + +// graphqlProjectLimit creates graphql.InputObject type needed to create/update satellite.Project. +func graphqlProjectLimit() *graphql.InputObject { + return graphql.NewInputObject(graphql.InputObjectConfig{ + Name: ProjectLimitType, + Fields: graphql.InputObjectConfigFieldMap{ + FieldStorageLimit: &graphql.InputObjectFieldConfig{ + Type: graphql.String, + }, + FieldBandwidthLimit: &graphql.InputObjectFieldConfig{ + Type: graphql.String, + }, + }, + }) +} + +// graphqlBucketUsageCursor creates bucket usage cursor graphql input type. +func graphqlProjectsCursor() *graphql.InputObject { + return graphql.NewInputObject(graphql.InputObjectConfig{ + Name: ProjectsCursorInputType, + Fields: graphql.InputObjectConfigFieldMap{ + LimitArg: &graphql.InputObjectFieldConfig{ + Type: graphql.NewNonNull(graphql.Int), + }, + PageArg: &graphql.InputObjectFieldConfig{ + Type: graphql.NewNonNull(graphql.Int), + }, + }, + }) +} + +// graphqlBucketUsageCursor creates bucket usage cursor graphql input type. +func graphqlBucketUsageCursor() *graphql.InputObject { + return graphql.NewInputObject(graphql.InputObjectConfig{ + Name: BucketUsageCursorInputType, + Fields: graphql.InputObjectConfigFieldMap{ + SearchArg: &graphql.InputObjectFieldConfig{ + Type: graphql.NewNonNull(graphql.String), + }, + LimitArg: &graphql.InputObjectFieldConfig{ + Type: graphql.NewNonNull(graphql.Int), + }, + PageArg: &graphql.InputObjectFieldConfig{ + Type: graphql.NewNonNull(graphql.Int), + }, + }, + }) +} + +// graphqlBucketUsage creates bucket usage grapqhl type. +func graphqlBucketUsage() *graphql.Object { + return graphql.NewObject(graphql.ObjectConfig{ + Name: BucketUsageType, + Fields: graphql.Fields{ + FieldBucketName: &graphql.Field{ + Type: graphql.String, + }, + FieldStorage: &graphql.Field{ + Type: graphql.Float, + }, + FieldEgress: &graphql.Field{ + Type: graphql.Float, + }, + FieldObjectCount: &graphql.Field{ + Type: graphql.Float, + }, + FieldSegmentCount: &graphql.Field{ + Type: graphql.Float, + }, + SinceArg: &graphql.Field{ + Type: graphql.DateTime, + }, + BeforeArg: &graphql.Field{ + Type: graphql.DateTime, + }, + }, + }) +} + +// graphqlProjectsPage creates a projects page graphql object. +func graphqlProjectsPage(types *TypeCreator) *graphql.Object { + return graphql.NewObject(graphql.ObjectConfig{ + Name: ProjectsPageType, + Fields: graphql.Fields{ + FieldProjects: &graphql.Field{ + Type: graphql.NewList(types.project), + }, + LimitArg: &graphql.Field{ + Type: graphql.Int, + }, + OffsetArg: &graphql.Field{ + Type: graphql.Int, + }, + FieldPageCount: &graphql.Field{ + Type: graphql.Int, + }, + FieldCurrentPage: &graphql.Field{ + Type: graphql.Int, + }, + FieldTotalCount: &graphql.Field{ + Type: graphql.Int, + }, + }, + }) +} + +// graphqlBucketUsagePage creates bucket usage page graphql object. +func graphqlBucketUsagePage(types *TypeCreator) *graphql.Object { + return graphql.NewObject(graphql.ObjectConfig{ + Name: BucketUsagePageType, + Fields: graphql.Fields{ + FieldBucketUsages: &graphql.Field{ + Type: graphql.NewList(types.bucketUsage), + }, + SearchArg: &graphql.Field{ + Type: graphql.String, + }, + LimitArg: &graphql.Field{ + Type: graphql.Int, + }, + OffsetArg: &graphql.Field{ + Type: graphql.Int, + }, + FieldPageCount: &graphql.Field{ + Type: graphql.Int, + }, + FieldCurrentPage: &graphql.Field{ + Type: graphql.Int, + }, + FieldTotalCount: &graphql.Field{ + Type: graphql.Int, + }, + }, + }) +} + +// graphqlProjectUsage creates project usage graphql type. +func graphqlProjectUsage() *graphql.Object { + return graphql.NewObject(graphql.ObjectConfig{ + Name: ProjectUsageType, + Fields: graphql.Fields{ + FieldStorage: &graphql.Field{ + Type: graphql.Float, + }, + FieldEgress: &graphql.Field{ + Type: graphql.Float, + }, + FieldObjectCount: &graphql.Field{ + Type: graphql.Float, + }, + FieldSegmentCount: &graphql.Field{ + Type: graphql.Float, + }, + SinceArg: &graphql.Field{ + Type: graphql.DateTime, + }, + BeforeArg: &graphql.Field{ + Type: graphql.DateTime, + }, + }, + }) +} + +// fromMapProjectInfo creates console.UpsertProjectInfo from input args. +func fromMapProjectInfo(args map[string]interface{}) (project console.UpsertProjectInfo) { + project.Name, _ = args[FieldName].(string) + project.Description, _ = args[FieldDescription].(string) + + return +} + +// fromMapProjectInfoProjectLimits creates console.UpsertProjectInfo from input args. +func fromMapProjectInfoProjectLimits(projectInfo, projectLimits map[string]interface{}) (project console.UpsertProjectInfo, err error) { + project.Name, _ = projectInfo[FieldName].(string) + project.Description, _ = projectInfo[FieldDescription].(string) + storageLimit, err := strconv.Atoi(projectLimits[FieldStorageLimit].(string)) + if err != nil { + return project, err + } + project.StorageLimit = memory.Size(storageLimit) + bandwidthLimit, err := strconv.Atoi(projectLimits[FieldBandwidthLimit].(string)) + if err != nil { + return project, err + } + project.BandwidthLimit = memory.Size(bandwidthLimit) + + return +} + +// fromMapProjectsCursor creates console.ProjectsCursor from input args. +func fromMapProjectsCursor(args map[string]interface{}) (cursor console.ProjectsCursor) { + cursor.Limit = args[LimitArg].(int) + cursor.Page = args[PageArg].(int) + return +} + +// fromMapBucketUsageCursor creates accounting.BucketUsageCursor from input args. +func fromMapBucketUsageCursor(args map[string]interface{}) (cursor accounting.BucketUsageCursor) { + limit, _ := args[LimitArg].(int) + page, _ := args[PageArg].(int) + + cursor.Limit = uint(limit) + cursor.Page = uint(page) + cursor.Search, _ = args[SearchArg].(string) + return +} + +func cursorArgsToProjectMembersCursor(args map[string]interface{}) console.ProjectMembersCursor { + limit, _ := args[LimitArg].(int) + page, _ := args[PageArg].(int) + order, _ := args[OrderArg].(int) + orderDirection, _ := args[OrderDirectionArg].(int) + + var cursor console.ProjectMembersCursor + + cursor.Limit = uint(limit) + cursor.Page = uint(page) + cursor.Order = console.ProjectMemberOrder(order) + cursor.OrderDirection = console.OrderDirection(orderDirection) + cursor.Search, _ = args[SearchArg].(string) + + return cursor +} + +func cursorArgsToAPIKeysCursor(args map[string]interface{}) console.APIKeyCursor { + limit, _ := args[LimitArg].(int) + page, _ := args[PageArg].(int) + order, _ := args[OrderArg].(int) + orderDirection, _ := args[OrderDirectionArg].(int) + + var cursor console.APIKeyCursor + + cursor.Limit = uint(limit) + cursor.Page = uint(page) + cursor.Order = console.APIKeyOrder(order) + cursor.OrderDirection = console.OrderDirection(orderDirection) + cursor.Search, _ = args[SearchArg].(string) + + return cursor +} diff --git a/satellite/console/consoleweb/consoleql/projectmember.go b/satellite/console/consoleweb/consoleql/projectmember.go new file mode 100644 index 000000000..da9599faf --- /dev/null +++ b/satellite/console/consoleweb/consoleql/projectmember.go @@ -0,0 +1,150 @@ +// Copyright (C) 2019 Storj Labs, Inc. +// See LICENSE for copying information. + +package consoleql + +import ( + "time" + + "github.com/graphql-go/graphql" + + "storj.io/storj/satellite/console" +) + +const ( + // ProjectMemberType is a graphql type name for project member. + ProjectMemberType = "projectMember" + // ProjectInvitationType is a graphql type name for project member invitation. + ProjectInvitationType = "projectInvitation" + // FieldJoinedAt is a field name for joined at timestamp. + FieldJoinedAt = "joinedAt" + // FieldExpired is a field name for expiration status. + FieldExpired = "expired" +) + +// graphqlProjectMember creates projectMember type. +func graphqlProjectMember(service *console.Service, types *TypeCreator) *graphql.Object { + return graphql.NewObject(graphql.ObjectConfig{ + Name: ProjectMemberType, + Fields: graphql.Fields{ + UserType: &graphql.Field{ + Type: types.user, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + member, _ := p.Source.(projectMember) + // company sub query expects pointer + return member.User, nil + }, + }, + FieldJoinedAt: &graphql.Field{ + Type: graphql.DateTime, + }, + }, + }) +} + +// graphqlProjectInvitation creates projectInvitation type. +func graphqlProjectInvitation() *graphql.Object { + return graphql.NewObject(graphql.ObjectConfig{ + Name: ProjectInvitationType, + Fields: graphql.Fields{ + FieldEmail: &graphql.Field{ + Type: graphql.String, + }, + FieldCreatedAt: &graphql.Field{ + Type: graphql.DateTime, + }, + FieldExpired: &graphql.Field{ + Type: graphql.Boolean, + }, + }, + }) +} + +func graphqlProjectMembersCursor() *graphql.InputObject { + return graphql.NewInputObject(graphql.InputObjectConfig{ + Name: ProjectMembersCursorInputType, + Fields: graphql.InputObjectConfigFieldMap{ + SearchArg: &graphql.InputObjectFieldConfig{ + Type: graphql.NewNonNull(graphql.String), + }, + LimitArg: &graphql.InputObjectFieldConfig{ + Type: graphql.NewNonNull(graphql.Int), + }, + PageArg: &graphql.InputObjectFieldConfig{ + Type: graphql.NewNonNull(graphql.Int), + }, + OrderArg: &graphql.InputObjectFieldConfig{ + Type: graphql.NewNonNull(graphql.Int), + }, + OrderDirectionArg: &graphql.InputObjectFieldConfig{ + Type: graphql.NewNonNull(graphql.Int), + }, + }, + }) +} + +func graphqlProjectMembersAndInvitationsPage(types *TypeCreator) *graphql.Object { + return graphql.NewObject(graphql.ObjectConfig{ + Name: ProjectMembersAndInvitationsPageType, + Fields: graphql.Fields{ + FieldProjectMembers: &graphql.Field{ + Type: graphql.NewList(types.projectMember), + }, + FieldProjectInvitations: &graphql.Field{ + Type: graphql.NewList(types.projectInvitation), + }, + SearchArg: &graphql.Field{ + Type: graphql.String, + }, + LimitArg: &graphql.Field{ + Type: graphql.Int, + }, + OrderArg: &graphql.Field{ + Type: graphql.Int, + }, + OrderDirectionArg: &graphql.Field{ + Type: graphql.Int, + }, + OffsetArg: &graphql.Field{ + Type: graphql.Int, + }, + FieldPageCount: &graphql.Field{ + Type: graphql.Int, + }, + FieldCurrentPage: &graphql.Field{ + Type: graphql.Int, + }, + FieldTotalCount: &graphql.Field{ + Type: graphql.Int, + }, + }, + }) +} + +// projectMember encapsulates User and joinedAt. +type projectMember struct { + User *console.User + JoinedAt time.Time +} + +// projectInvitation encapsulates a console.ProjectInvitation and its expiration status. +type projectInvitation struct { + Email string + CreatedAt time.Time + Expired bool +} + +type projectMembersPage struct { + ProjectMembers []projectMember + ProjectInvitations []projectInvitation + + Search string + Limit uint + Order int + OrderDirection int + Offset uint64 + + PageCount uint + CurrentPage uint + TotalCount uint64 +} diff --git a/satellite/console/consoleweb/consoleql/query.go b/satellite/console/consoleweb/consoleql/query.go new file mode 100644 index 000000000..4020e4888 --- /dev/null +++ b/satellite/console/consoleweb/consoleql/query.go @@ -0,0 +1,82 @@ +// Copyright (C) 2019 Storj Labs, Inc. +// See LICENSE for copying information. + +package consoleql + +import ( + "github.com/graphql-go/graphql" + + "storj.io/storj/satellite/console" + "storj.io/storj/satellite/mailservice" +) + +const ( + // Query is immutable graphql request. + Query = "query" + // ProjectQuery is a query name for project. + ProjectQuery = "project" + // OwnedProjectsQuery is a query name for projects owned by an account. + OwnedProjectsQuery = "ownedProjects" + // MyProjectsQuery is a query name for projects related to account. + MyProjectsQuery = "myProjects" +) + +// rootQuery creates query for graphql populated by AccountsClient. +func rootQuery(service *console.Service, mailService *mailservice.Service, types *TypeCreator) *graphql.Object { + return graphql.NewObject(graphql.ObjectConfig{ + Name: Query, + Fields: graphql.Fields{ + ProjectQuery: &graphql.Field{ + Type: types.project, + Args: graphql.FieldConfigArgument{ + FieldID: &graphql.ArgumentConfig{ + Type: graphql.String, + DefaultValue: "", + }, + FieldPublicID: &graphql.ArgumentConfig{ + Type: graphql.String, + DefaultValue: "", + }, + }, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + + projectID, err := getProjectID(p) + if err != nil { + return nil, err + } + + project, err := service.GetProject(p.Context, projectID) + if err != nil { + return nil, err + } + + return project, nil + }, + }, + OwnedProjectsQuery: &graphql.Field{ + Type: types.projectsPage, + Args: graphql.FieldConfigArgument{ + CursorArg: &graphql.ArgumentConfig{ + Type: graphql.NewNonNull(types.projectsCursor), + }, + }, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + cursor := fromMapProjectsCursor(p.Args[CursorArg].(map[string]interface{})) + page, err := service.GetUsersOwnedProjectsPage(p.Context, cursor) + return page, err + }, + }, + MyProjectsQuery: &graphql.Field{ + Type: graphql.NewList(types.project), + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + projects, err := service.GetUsersProjects(p.Context) + if err != nil { + return nil, err + } + + return projects, nil + }, + }, + }, + }) +} diff --git a/satellite/console/consoleweb/consoleql/query_test.go b/satellite/console/consoleweb/consoleql/query_test.go new file mode 100644 index 000000000..f158114ab --- /dev/null +++ b/satellite/console/consoleweb/consoleql/query_test.go @@ -0,0 +1,501 @@ +// Copyright (C) 2019 Storj Labs, Inc. +// See LICENSE for copying information. + +package consoleql_test + +import ( + "fmt" + "testing" + "time" + + "github.com/graphql-go/graphql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + + "storj.io/common/testcontext" + "storj.io/storj/private/testplanet" + "storj.io/storj/private/testredis" + "storj.io/storj/satellite/accounting" + "storj.io/storj/satellite/accounting/live" + "storj.io/storj/satellite/analytics" + "storj.io/storj/satellite/console" + "storj.io/storj/satellite/console/consoleauth" + "storj.io/storj/satellite/console/consoleweb/consoleql" + "storj.io/storj/satellite/console/restkeys" + "storj.io/storj/satellite/mailservice" + "storj.io/storj/satellite/payments" + "storj.io/storj/satellite/payments/paymentsconfig" + "storj.io/storj/satellite/payments/stripe" +) + +func TestGraphqlQuery(t *testing.T) { + testplanet.Run(t, testplanet.Config{SatelliteCount: 1}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { + sat := planet.Satellites[0] + db := sat.DB + log := zaptest.NewLogger(t) + + analyticsService := analytics.NewService(log, analytics.Config{}, "test-satellite") + + redis, err := testredis.Mini(ctx) + require.NoError(t, err) + defer ctx.Check(redis.Close) + + cache, err := live.OpenCache(ctx, log.Named("cache"), live.Config{StorageBackend: "redis://" + redis.Addr() + "?db=0"}) + require.NoError(t, err) + + projectLimitCache := accounting.NewProjectLimitCache(db.ProjectAccounting(), 0, 0, 0, accounting.ProjectLimitConfig{CacheCapacity: 100}) + + projectUsage := accounting.NewService(db.ProjectAccounting(), cache, projectLimitCache, *sat.Metabase.DB, 5*time.Minute, -10*time.Second) + + // TODO maybe switch this test to testplanet to avoid defining config and Stripe service + pc := paymentsconfig.Config{ + UsagePrice: paymentsconfig.ProjectUsagePrice{ + StorageTB: "10", + EgressTB: "45", + Segment: "0.0000022", + }, + } + + prices, err := pc.UsagePrice.ToModel() + require.NoError(t, err) + + priceOverrides, err := pc.UsagePriceOverrides.ToModels() + require.NoError(t, err) + + paymentsService, err := stripe.NewService( + log.Named("payments.stripe:service"), + stripe.NewStripeMock( + db.StripeCoinPayments().Customers(), + db.Console().Users(), + ), + pc.StripeCoinPayments, + db.StripeCoinPayments(), + db.Wallets(), + db.Billing(), + db.Console().Projects(), + db.Console().Users(), + db.ProjectAccounting(), + prices, + priceOverrides, + pc.PackagePlans.Packages, + pc.BonusRate, + nil, + ) + require.NoError(t, err) + + service, err := console.NewService( + log.Named("console"), + db.Console(), + restkeys.NewService(db.OIDC().OAuthTokens(), planet.Satellites[0].Config.RESTKeys), + db.ProjectAccounting(), + projectUsage, + sat.API.Buckets.Service, + paymentsService.Accounts(), + // TODO: do we need a payment deposit wallet here? + nil, + db.Billing(), + analyticsService, + consoleauth.NewService(consoleauth.Config{ + TokenExpirationTime: 24 * time.Hour, + }, &consoleauth.Hmac{Secret: []byte("my-suppa-secret-key")}), + nil, + "", + "", + console.Config{ + PasswordCost: console.TestPasswordCost, + DefaultProjectLimit: 5, + Session: console.SessionConfig{ + Duration: time.Hour, + }, + }, + ) + require.NoError(t, err) + + mailService, err := mailservice.New(log, &discardSender{}, "testdata") + require.NoError(t, err) + defer ctx.Check(mailService.Close) + + rootObject := make(map[string]interface{}) + rootObject["origin"] = "http://doesntmatter.com/" + rootObject[consoleql.ActivationPath] = "?activationToken=" + rootObject[consoleql.LetUsKnowURL] = "letUsKnowURL" + rootObject[consoleql.ContactInfoURL] = "contactInfoURL" + rootObject[consoleql.TermsAndConditionsURL] = "termsAndConditionsURL" + + creator := consoleql.TypeCreator{} + err = creator.Create(log, service, mailService) + require.NoError(t, err) + + schema, err := graphql.NewSchema(graphql.SchemaConfig{ + Query: creator.RootQuery(), + Mutation: creator.RootMutation(), + }) + require.NoError(t, err) + + createUser := console.CreateUser{ + FullName: "John", + ShortName: "", + Email: "mtest@mail.test", + Password: "123a123", + SignupPromoCode: "promo1", + } + + regToken, err := service.CreateRegToken(ctx, 2) + require.NoError(t, err) + + rootUser, err := service.CreateUser(ctx, createUser, regToken.Secret) + require.NoError(t, err) + + couponType, err := paymentsService.Accounts().Setup(ctx, rootUser.ID, rootUser.Email, rootUser.SignupPromoCode) + + var signupCouponType payments.CouponType = payments.SignupCoupon + + require.NoError(t, err) + assert.Equal(t, signupCouponType, couponType) + + t.Run("Activation", func(t *testing.T) { + activationToken, err := service.GenerateActivationToken( + ctx, + rootUser.ID, + "mtest@mail.test", + ) + require.NoError(t, err) + _, err = service.ActivateAccount(ctx, activationToken) + require.NoError(t, err) + rootUser.Email = "mtest@mail.test" + }) + + tokenInfo, err := service.Token(ctx, console.AuthUser{Email: createUser.Email, Password: createUser.Password}) + require.NoError(t, err) + + userCtx, err := service.TokenAuth(ctx, tokenInfo.Token, time.Now()) + require.NoError(t, err) + + testQuery := func(t *testing.T, query string) interface{} { + result := graphql.Do(graphql.Params{ + Schema: schema, + Context: userCtx, + RequestString: query, + RootObject: rootObject, + }) + + for _, err := range result.Errors { + assert.NoError(t, err) + } + require.False(t, result.HasErrors()) + + return result.Data + } + + createdProject, err := service.CreateProject(userCtx, console.UpsertProjectInfo{ + Name: "TestProject", + }) + require.NoError(t, err) + + // "query {project(id:\"%s\"){id,name,members(offset:0, limit:50){user{fullName,shortName,email}},apiKeys{name,id,createdAt,projectID}}}" + t.Run("Project query base info", func(t *testing.T) { + query := fmt.Sprintf( + "query {project(id:\"%s\"){id,name,publicId,description,createdAt}}", + createdProject.ID.String(), + ) + + result := testQuery(t, query) + + data := result.(map[string]interface{}) + project := data[consoleql.ProjectQuery].(map[string]interface{}) + + assert.Equal(t, createdProject.ID.String(), project[consoleql.FieldID]) + assert.Equal(t, createdProject.PublicID.String(), project[consoleql.FieldPublicID]) + assert.Equal(t, createdProject.Name, project[consoleql.FieldName]) + assert.Equal(t, createdProject.Description, project[consoleql.FieldDescription]) + + createdAt := time.Time{} + err := createdAt.UnmarshalText([]byte(project[consoleql.FieldCreatedAt].(string))) + + assert.NoError(t, err) + assert.True(t, createdProject.CreatedAt.Equal(createdAt)) + + // test getting by publicId + query = fmt.Sprintf( + "query {project(publicId:\"%s\"){id,name,publicId,description,createdAt}}", + createdProject.PublicID.String(), + ) + + result = testQuery(t, query) + + data = result.(map[string]interface{}) + project = data[consoleql.ProjectQuery].(map[string]interface{}) + + assert.Equal(t, createdProject.ID.String(), project[consoleql.FieldID]) + assert.Equal(t, createdProject.PublicID.String(), project[consoleql.FieldPublicID]) + }) + + regTokenUser1, err := service.CreateRegToken(ctx, 2) + require.NoError(t, err) + + user1, err := service.CreateUser(userCtx, console.CreateUser{ + FullName: "Mickey Last", + ShortName: "Last", + Password: "123a123", + Email: "muu1@mail.test", + }, regTokenUser1.Secret) + require.NoError(t, err) + + t.Run("Activation", func(t *testing.T) { + activationToken1, err := service.GenerateActivationToken( + ctx, + user1.ID, + "muu1@mail.test", + ) + require.NoError(t, err) + _, err = service.ActivateAccount(ctx, activationToken1) + require.NoError(t, err) + user1.Email = "muu1@mail.test" + }) + + regTokenUser2, err := service.CreateRegToken(ctx, 2) + require.NoError(t, err) + + user2, err := service.CreateUser(userCtx, console.CreateUser{ + FullName: "Dubas Name", + ShortName: "Name", + Email: "muu2@mail.test", + Password: "123a123", + }, regTokenUser2.Secret) + require.NoError(t, err) + + t.Run("Activation", func(t *testing.T) { + activationToken2, err := service.GenerateActivationToken( + ctx, + user2.ID, + "muu2@mail.test", + ) + require.NoError(t, err) + _, err = service.ActivateAccount(ctx, activationToken2) + require.NoError(t, err) + user2.Email = "muu2@mail.test" + }) + + users, err := service.AddProjectMembers(userCtx, createdProject.ID, []string{ + user1.Email, + user2.Email, + }) + require.NoError(t, err) + assert.Equal(t, 2, len(users)) + + t.Run("Project query team members", func(t *testing.T) { + query := fmt.Sprintf( + "query {project(id: \"%s\") {membersAndInvitations( cursor: { limit: %d, search: \"%s\", page: %d, order: %d, orderDirection: %d } ) { projectMembers{ user { id, fullName, shortName, email, createdAt }, joinedAt }, search, limit, order, offset, pageCount, currentPage, totalCount } } }", + createdProject.ID.String(), + 5, + "", + 1, + 1, + 2) + + result := testQuery(t, query) + + data := result.(map[string]interface{}) + project := data[consoleql.ProjectQuery].(map[string]interface{}) + members := project[consoleql.FieldMembersAndInvitations].(map[string]interface{}) + projectMembers := members[consoleql.FieldProjectMembers].([]interface{}) + + assert.Equal(t, 3, len(projectMembers)) + + testUser := func(t *testing.T, actual map[string]interface{}, expected *console.User) { + assert.Equal(t, expected.Email, actual[consoleql.FieldEmail]) + assert.Equal(t, expected.FullName, actual[consoleql.FieldFullName]) + assert.Equal(t, expected.ShortName, actual[consoleql.FieldShortName]) + + createdAt := time.Time{} + err := createdAt.UnmarshalText([]byte(actual[consoleql.FieldCreatedAt].(string))) + + assert.NoError(t, err) + assert.True(t, expected.CreatedAt.Equal(createdAt)) + } + + var foundRoot, foundU1, foundU2 bool + + for _, entry := range projectMembers { + member := entry.(map[string]interface{}) + user := member[consoleql.UserType].(map[string]interface{}) + + id := user[consoleql.FieldID].(string) + switch id { + case rootUser.ID.String(): + foundRoot = true + testUser(t, user, rootUser) + case user1.ID.String(): + foundU1 = true + testUser(t, user, user1) + case user2.ID.String(): + foundU2 = true + testUser(t, user, user2) + } + } + + assert.True(t, foundRoot) + assert.True(t, foundU1) + assert.True(t, foundU2) + }) + + keyInfo1, _, err := service.CreateAPIKey(userCtx, createdProject.ID, "key1") + require.NoError(t, err) + + keyInfo2, _, err := service.CreateAPIKey(userCtx, createdProject.ID, "key2") + require.NoError(t, err) + + t.Run("Project query api keys", func(t *testing.T) { + query := fmt.Sprintf( + "query {project(id: \"%s\") {apiKeys( cursor: { limit: %d, search: \"%s\", page: %d, order: %d, orderDirection: %d } ) { apiKeys { id, name, createdAt, projectID }, search, limit, order, offset, pageCount, currentPage, totalCount } } }", + createdProject.ID.String(), + 5, + "", + 1, + 1, + 2) + + result := testQuery(t, query) + + data := result.(map[string]interface{}) + project := data[consoleql.ProjectQuery].(map[string]interface{}) + keys := project[consoleql.FieldAPIKeys].(map[string]interface{}) + apiKeys := keys[consoleql.FieldAPIKeys].([]interface{}) + + assert.Equal(t, 2, len(apiKeys)) + + testAPIKey := func(t *testing.T, actual map[string]interface{}, expected *console.APIKeyInfo) { + assert.Equal(t, expected.Name, actual[consoleql.FieldName]) + assert.Equal(t, expected.ProjectID.String(), actual[consoleql.FieldProjectID]) + + createdAt := time.Time{} + err := createdAt.UnmarshalText([]byte(actual[consoleql.FieldCreatedAt].(string))) + + assert.NoError(t, err) + assert.True(t, expected.CreatedAt.Equal(createdAt)) + } + + var foundKey1, foundKey2 bool + + for _, entry := range apiKeys { + key := entry.(map[string]interface{}) + + id := key[consoleql.FieldID].(string) + switch id { + case keyInfo1.ID.String(): + foundKey1 = true + testAPIKey(t, key, keyInfo1) + case keyInfo2.ID.String(): + foundKey2 = true + testAPIKey(t, key, keyInfo2) + } + } + + assert.True(t, foundKey1) + assert.True(t, foundKey2) + }) + + project2, err := service.CreateProject(userCtx, console.UpsertProjectInfo{ + Name: "Project2", + Description: "Test desc", + }) + require.NoError(t, err) + + t.Run("MyProjects query", func(t *testing.T) { + query := "query {myProjects{id,publicId,name,description,createdAt}}" + + result := testQuery(t, query) + + data := result.(map[string]interface{}) + projectsList := data[consoleql.MyProjectsQuery].([]interface{}) + + assert.Equal(t, 2, len(projectsList)) + + testProject := func(t *testing.T, actual map[string]interface{}, expected *console.Project) { + assert.Equal(t, expected.Name, actual[consoleql.FieldName]) + assert.Equal(t, expected.PublicID.String(), actual[consoleql.FieldPublicID]) + assert.Equal(t, expected.Description, actual[consoleql.FieldDescription]) + + createdAt := time.Time{} + err := createdAt.UnmarshalText([]byte(actual[consoleql.FieldCreatedAt].(string))) + + assert.NoError(t, err) + assert.True(t, expected.CreatedAt.Equal(createdAt)) + } + + var foundProj1, foundProj2 bool + + for _, entry := range projectsList { + project := entry.(map[string]interface{}) + + id := project[consoleql.FieldID].(string) + switch id { + case createdProject.ID.String(): + foundProj1 = true + testProject(t, project, createdProject) + case project2.ID.String(): + foundProj2 = true + testProject(t, project, project2) + } + } + + assert.True(t, foundProj1) + assert.True(t, foundProj2) + }) + t.Run("OwnedProjects query", func(t *testing.T) { + query := fmt.Sprintf( + "query {ownedProjects( cursor: { limit: %d, page: %d } ) {projects{id, publicId, name, ownerId, description, createdAt, memberCount}, limit, offset, pageCount, currentPage, totalCount } }", + 5, + 1, + ) + + result := testQuery(t, query) + + data := result.(map[string]interface{}) + projectsPage := data[consoleql.OwnedProjectsQuery].(map[string]interface{}) + + projectsList := projectsPage[consoleql.FieldProjects].([]interface{}) + assert.Len(t, projectsList, 2) + + assert.EqualValues(t, 1, projectsPage[consoleql.FieldCurrentPage]) + assert.EqualValues(t, 0, projectsPage[consoleql.OffsetArg]) + assert.EqualValues(t, 5, projectsPage[consoleql.LimitArg]) + assert.EqualValues(t, 1, projectsPage[consoleql.FieldPageCount]) + assert.EqualValues(t, 2, projectsPage[consoleql.FieldTotalCount]) + + testProject := func(t *testing.T, actual map[string]interface{}, expected *console.Project, expectedNumMembers int) { + assert.Equal(t, expected.Name, actual[consoleql.FieldName]) + assert.Equal(t, expected.PublicID.String(), actual[consoleql.FieldPublicID]) + assert.Equal(t, expected.Description, actual[consoleql.FieldDescription]) + + createdAt := time.Time{} + err := createdAt.UnmarshalText([]byte(actual[consoleql.FieldCreatedAt].(string))) + + assert.NoError(t, err) + assert.True(t, expected.CreatedAt.Equal(createdAt)) + + assert.EqualValues(t, expectedNumMembers, actual[consoleql.FieldMemberCount]) + } + + var foundProj1, foundProj2 bool + + for _, entry := range projectsList { + project := entry.(map[string]interface{}) + + id := project[consoleql.FieldID].(string) + switch id { + case createdProject.ID.String(): + foundProj1 = true + testProject(t, project, createdProject, 3) + case project2.ID.String(): + foundProj2 = true + testProject(t, project, project2, 1) + } + } + + assert.True(t, foundProj1) + assert.True(t, foundProj2) + }) + }) +} diff --git a/satellite/console/consoleweb/consoleql/reward.go b/satellite/console/consoleweb/consoleql/reward.go new file mode 100644 index 000000000..cdf891701 --- /dev/null +++ b/satellite/console/consoleweb/consoleql/reward.go @@ -0,0 +1,64 @@ +// Copyright (C) 2019 Storj Labs, Inc. +// See LICENSE for copying information. + +package consoleql + +import ( + "github.com/graphql-go/graphql" +) + +const ( + // RewardType is a graphql type for reward. + RewardType = "reward" + // FieldAwardCreditInCent is a field name for award credit amount for referrers. + FieldAwardCreditInCent = "awardCreditInCent" + // FieldInviteeCreditInCents is a field name for credit amount rewarded to invitees. + FieldInviteeCreditInCents = "inviteeCreditInCents" + // FieldRedeemableCap is a field name for the total redeemable amount of the reward offer. + FieldRedeemableCap = "redeemableCap" + // FieldAwardCreditDurationDays is a field name for the valid time frame of current award credit. + FieldAwardCreditDurationDays = "awardCreditDurationDays" + // FieldInviteeCreditDurationDays is a field name for the valid time frame of current invitee credit. + FieldInviteeCreditDurationDays = "inviteeCreditDurationDays" + // FieldExpiresAt is a field name for the expiration time of a reward offer. + FieldExpiresAt = "expiresAt" + // FieldType is a field name for the type of reward offers. + FieldType = "type" + // FieldStatus is a field name for the status of reward offers. + FieldStatus = "status" +) + +func graphqlReward() *graphql.Object { + return graphql.NewObject(graphql.ObjectConfig{ + Name: RewardType, + Fields: graphql.Fields{ + FieldID: &graphql.Field{ + Type: graphql.Int, + }, + FieldAwardCreditInCent: &graphql.Field{ + Type: graphql.Int, + }, + FieldInviteeCreditInCents: &graphql.Field{ + Type: graphql.Int, + }, + FieldRedeemableCap: &graphql.Field{ + Type: graphql.Int, + }, + FieldAwardCreditDurationDays: &graphql.Field{ + Type: graphql.Int, + }, + FieldInviteeCreditDurationDays: &graphql.Field{ + Type: graphql.Int, + }, + FieldType: &graphql.Field{ + Type: graphql.Int, + }, + FieldStatus: &graphql.Field{ + Type: graphql.Int, + }, + FieldExpiresAt: &graphql.Field{ + Type: graphql.String, + }, + }, + }) +} diff --git a/satellite/console/consoleweb/consoleql/schema.go b/satellite/console/consoleweb/consoleql/schema.go new file mode 100644 index 000000000..553a64802 --- /dev/null +++ b/satellite/console/consoleweb/consoleql/schema.go @@ -0,0 +1,27 @@ +// Copyright (C) 2018 Storj Labs, Inc. +// See LICENSE for copying information. + +package consoleql + +import ( + "github.com/graphql-go/graphql" + "go.uber.org/zap" + + "storj.io/storj/satellite/console" + "storj.io/storj/satellite/mailservice" +) + +// CreateSchema creates a schema for satellites console graphql api. +func CreateSchema(log *zap.Logger, service *console.Service, mailService *mailservice.Service) (schema graphql.Schema, err error) { + creator := TypeCreator{} + + err = creator.Create(log, service, mailService) + if err != nil { + return + } + + return graphql.NewSchema(graphql.SchemaConfig{ + Query: creator.RootQuery(), + Mutation: creator.RootMutation(), + }) +} diff --git a/satellite/console/consoleweb/consoleql/testdata/Invite.html b/satellite/console/consoleweb/consoleql/testdata/Invite.html new file mode 100644 index 000000000..6e567cf4d --- /dev/null +++ b/satellite/console/consoleweb/consoleql/testdata/Invite.html @@ -0,0 +1,612 @@ + + + + + + + + + + + + + + + + + diff --git a/satellite/console/consoleweb/consoleql/testdata/Welcome.html b/satellite/console/consoleweb/consoleql/testdata/Welcome.html new file mode 100644 index 000000000..8607d5ad2 --- /dev/null +++ b/satellite/console/consoleweb/consoleql/testdata/Welcome.html @@ -0,0 +1,640 @@ + + + + + + + + + + + + + + + + + diff --git a/satellite/console/consoleweb/consoleql/typecreator.go b/satellite/console/consoleweb/consoleql/typecreator.go new file mode 100644 index 000000000..9393a21fe --- /dev/null +++ b/satellite/console/consoleweb/consoleql/typecreator.go @@ -0,0 +1,168 @@ +// Copyright (C) 2019 Storj Labs, Inc. +// See LICENSE for copying information. + +package consoleql + +import ( + "github.com/graphql-go/graphql" + "go.uber.org/zap" + + "storj.io/storj/satellite/console" + "storj.io/storj/satellite/mailservice" +) + +// TypeCreator handles graphql type creation and error checking. +type TypeCreator struct { + query *graphql.Object + mutation *graphql.Object + + user *graphql.Object + reward *graphql.Object + project *graphql.Object + projectUsage *graphql.Object + projectsPage *graphql.Object + bucketUsage *graphql.Object + bucketUsagePage *graphql.Object + projectMember *graphql.Object + projectInvitation *graphql.Object + projectMembersAndInvitationsPage *graphql.Object + apiKeyPage *graphql.Object + apiKeyInfo *graphql.Object + createAPIKey *graphql.Object + + userInput *graphql.InputObject + projectInput *graphql.InputObject + projectLimit *graphql.InputObject + projectsCursor *graphql.InputObject + bucketUsageCursor *graphql.InputObject + projectMembersCursor *graphql.InputObject + apiKeysCursor *graphql.InputObject +} + +// Create create types and check for error. +func (c *TypeCreator) Create(log *zap.Logger, service *console.Service, mailService *mailservice.Service) error { + // inputs + c.userInput = graphqlUserInput() + if err := c.userInput.Error(); err != nil { + return err + } + + c.projectInput = graphqlProjectInput() + if err := c.projectInput.Error(); err != nil { + return err + } + + c.projectLimit = graphqlProjectLimit() + if err := c.projectLimit.Error(); err != nil { + return err + } + + c.bucketUsageCursor = graphqlBucketUsageCursor() + if err := c.bucketUsageCursor.Error(); err != nil { + return err + } + + c.projectMembersCursor = graphqlProjectMembersCursor() + if err := c.projectMembersCursor.Error(); err != nil { + return err + } + + c.apiKeysCursor = graphqlAPIKeysCursor() + if err := c.apiKeysCursor.Error(); err != nil { + return err + } + + // entities + c.user = graphqlUser() + if err := c.user.Error(); err != nil { + return err + } + + c.reward = graphqlReward() + if err := c.reward.Error(); err != nil { + return err + } + + c.projectUsage = graphqlProjectUsage() + if err := c.projectUsage.Error(); err != nil { + return err + } + + c.bucketUsage = graphqlBucketUsage() + if err := c.bucketUsage.Error(); err != nil { + return err + } + + c.bucketUsagePage = graphqlBucketUsagePage(c) + if err := c.bucketUsagePage.Error(); err != nil { + return err + } + + c.apiKeyInfo = graphqlAPIKeyInfo() + if err := c.apiKeyInfo.Error(); err != nil { + return err + } + + c.createAPIKey = graphqlCreateAPIKey(c) + if err := c.createAPIKey.Error(); err != nil { + return err + } + + c.projectMember = graphqlProjectMember(service, c) + if err := c.projectMember.Error(); err != nil { + return err + } + + c.projectInvitation = graphqlProjectInvitation() + if err := c.projectInvitation.Error(); err != nil { + return err + } + + c.projectMembersAndInvitationsPage = graphqlProjectMembersAndInvitationsPage(c) + if err := c.projectMembersAndInvitationsPage.Error(); err != nil { + return err + } + + c.apiKeyPage = graphqlAPIKeysPage(c) + if err := c.apiKeyPage.Error(); err != nil { + return err + } + + c.project = graphqlProject(service, c) + if err := c.project.Error(); err != nil { + return err + } + + c.projectsCursor = graphqlProjectsCursor() + if err := c.projectsCursor.Error(); err != nil { + return err + } + + c.projectsPage = graphqlProjectsPage(c) + if err := c.projectsPage.Error(); err != nil { + return err + } + + // root objects + c.query = rootQuery(service, mailService, c) + if err := c.query.Error(); err != nil { + return err + } + + c.mutation = rootMutation(log, service, mailService, c) + if err := c.mutation.Error(); err != nil { + return err + } + + return nil +} + +// RootQuery returns instance of query *graphql.Object. +func (c *TypeCreator) RootQuery() *graphql.Object { + return c.query +} + +// RootMutation returns instance of mutation *graphql.Object. +func (c *TypeCreator) RootMutation() *graphql.Object { + return c.mutation +} diff --git a/satellite/console/consoleweb/consoleql/user.go b/satellite/console/consoleweb/consoleql/user.go new file mode 100644 index 000000000..b810ecc14 --- /dev/null +++ b/satellite/console/consoleweb/consoleql/user.go @@ -0,0 +1,78 @@ +// Copyright (C) 2019 Storj Labs, Inc. +// See LICENSE for copying information. + +package consoleql + +import ( + "github.com/graphql-go/graphql" +) + +const ( + // UserType is a graphql type for user. + UserType = "user" + // UserInputType is a graphql type for user input. + UserInputType = "userInput" + // FieldID is a field name for id. + FieldID = "id" + // FieldEmail is a field name for email. + FieldEmail = "email" + // FieldPassword is a field name for password. + FieldPassword = "password" + // FieldFullName is a field name for "first name". + FieldFullName = "fullName" + // FieldShortName is a field name for "last name". + FieldShortName = "shortName" + // FieldCreatedAt is a field name for created at timestamp. + FieldCreatedAt = "createdAt" +) + +// base graphql config for user. +func baseUserConfig() graphql.ObjectConfig { + return graphql.ObjectConfig{ + Name: UserType, + Fields: graphql.Fields{ + FieldID: &graphql.Field{ + Type: graphql.String, + }, + FieldEmail: &graphql.Field{ + Type: graphql.String, + }, + FieldFullName: &graphql.Field{ + Type: graphql.String, + }, + FieldShortName: &graphql.Field{ + Type: graphql.String, + }, + FieldCreatedAt: &graphql.Field{ + Type: graphql.DateTime, + }, + }, + } +} + +// graphqlUser creates *graphql.Object type representation of satellite.User. +func graphqlUser() *graphql.Object { + // TODO: simplify + return graphql.NewObject(baseUserConfig()) +} + +// graphqlUserInput creates graphql.InputObject type needed to register/update satellite.User. +func graphqlUserInput() *graphql.InputObject { + return graphql.NewInputObject(graphql.InputObjectConfig{ + Name: UserInputType, + Fields: graphql.InputObjectConfigFieldMap{ + FieldEmail: &graphql.InputObjectFieldConfig{ + Type: graphql.String, + }, + FieldFullName: &graphql.InputObjectFieldConfig{ + Type: graphql.String, + }, + FieldShortName: &graphql.InputObjectFieldConfig{ + Type: graphql.String, + }, + FieldPassword: &graphql.InputObjectFieldConfig{ + Type: graphql.String, + }, + }, + }) +} diff --git a/satellite/console/consoleweb/endpoints_test.go b/satellite/console/consoleweb/endpoints_test.go index 0b94b8739..7ccd1fde0 100644 --- a/satellite/console/consoleweb/endpoints_test.go +++ b/satellite/console/consoleweb/endpoints_test.go @@ -376,6 +376,66 @@ func TestAPIKeys(t *testing.T) { user := test.defaultUser() test.login(user.email, user.password) + { // Post_GenerateApiKey + resp, body := test.request(http.MethodPost, "/graphql", + test.toJSON(map[string]interface{}{ + "variables": map[string]interface{}{ + "projectId": test.defaultProjectID(), + "name": user.email, + }, + "query": ` + mutation ($projectId: String!, $name: String!) { + createAPIKey(projectID: $projectId, name: $name) { + key + keyInfo { + id + name + createdAt + __typename + } + __typename + } + }`})) + require.Contains(t, body, "createAPIKey") + require.Equal(t, http.StatusOK, resp.StatusCode) + } + + { // Get_APIKeyInfoByProjectId + resp, body := test.request(http.MethodPost, "/graphql", + test.toJSON(map[string]interface{}{ + "variables": map[string]interface{}{ + "orderDirection": 1, + "projectId": test.defaultProjectID(), + "limit": 6, + "search": ``, + "page": 1, + "order": 1, + }, + "query": ` + query ($projectId: String!, $limit: Int!, $search: String!, $page: Int!, $order: Int!, $orderDirection: Int!) { + project(id: $projectId) { + apiKeys(cursor: {limit: $limit, search: $search, page: $page, order: $order, orderDirection: $orderDirection}) { + apiKeys { + id + name + createdAt + __typename + } + search + limit + order + pageCount + currentPage + totalCount + __typename + } + __typename + } + }`})) + require.Contains(t, body, "apiKeysPage") + require.Equal(t, http.StatusOK, resp.StatusCode) + } + { // Get_ProjectAPIKeys var projects console.APIKeyPage path := "/api-keys/list-paged?projectID=" + test.defaultProjectID() + "&search=''&limit=6&page=1&order=1&orderDirection=1" @@ -429,6 +489,24 @@ func TestProjects(t *testing.T) { user := test.defaultUser() test.login(user.email, user.password) + { // Get_ProjectId + resp, body := test.request(http.MethodPost, "/graphql", + test.toJSON(map[string]interface{}{ + "query": ` + { + myProjects { + name + id + description + createdAt + ownerId + __typename + } + }`})) + require.Contains(t, body, test.defaultProjectID()) + require.Equal(t, http.StatusOK, resp.StatusCode) + } + { // Get_Salt projectID := test.defaultProjectID() id, err := uuid.FromString(projectID) @@ -469,6 +547,45 @@ func TestProjects(t *testing.T) { require.NotEmpty(t, projects) } + { // Get_ProjectInfo + resp, body := test.request(http.MethodPost, "/graphql", + test.toJSON(map[string]interface{}{ + "variables": map[string]interface{}{ + "projectId": test.defaultProjectID(), + "before": "2021-05-12T18:32:30.533Z", + "limit": 7, + "search": "", + "page": 1, + }, + "query": ` + query ($projectId: String!, $before: DateTime!, $limit: Int!, $search: String!, $page: Int!) { + project(id: $projectId) { + bucketUsages(before: $before, cursor: {limit: $limit, search: $search, page: $page}) { + bucketUsages { + bucketName + storage + egress + objectCount + segmentCount + since + before + __typename + } + search + limit + offset + pageCount + currentPage + totalCount + __typename + } + __typename + } + }`})) + require.Contains(t, body, "bucketUsagePage") + require.Equal(t, http.StatusOK, resp.StatusCode) + } + { // Get_ProjectUsageLimitById resp, body := test.request(http.MethodGet, `/projects/`+test.defaultProjectID()+`/usage-limits`, nil) require.Equal(t, http.StatusOK, resp.StatusCode) diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index 9b026dafc..adc5331b3 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -24,6 +24,8 @@ import ( "time" "github.com/gorilla/mux" + "github.com/graphql-go/graphql" + "github.com/graphql-go/graphql/gqlerrors" "github.com/spacemonkeygo/monkit/v3" "github.com/zeebo/errs" "go.uber.org/zap" @@ -39,6 +41,7 @@ import ( "storj.io/storj/satellite/analytics" "storj.io/storj/satellite/console" "storj.io/storj/satellite/console/consoleweb/consoleapi" + "storj.io/storj/satellite/console/consoleweb/consoleql" "storj.io/storj/satellite/console/consoleweb/consolewebauth" "storj.io/storj/satellite/mailservice" "storj.io/storj/satellite/oidc" @@ -48,7 +51,8 @@ import ( const ( contentType = "Content-Type" - applicationJSON = "application/json" + applicationJSON = "application/json" + applicationGraphql = "application/graphql" ) var ( @@ -148,6 +152,8 @@ type Server struct { packagePlans paymentsconfig.PackagePlans + schema graphql.Schema + errorTemplate *template.Template } @@ -263,6 +269,8 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc router.Handle("/api/v0/config", server.withCORS(http.HandlerFunc(server.frontendConfigHandler))) + router.Handle("/api/v0/graphql", server.withCORS(server.withAuth(http.HandlerFunc(server.graphqlHandler)))) + router.HandleFunc("/registrationToken/", server.createRegistrationTokenHandler) router.HandleFunc("/robots.txt", server.seoHandler) @@ -413,6 +421,11 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, oidc func (server *Server) Run(ctx context.Context) (err error) { defer mon.Task()(&ctx)(&err) + server.schema, err = consoleql.CreateSchema(server.log, server.service, server.mailService) + if err != nil { + return Error.Wrap(err) + } + _, err = server.loadErrorTemplate() if err != nil { return Error.Wrap(err) @@ -924,6 +937,130 @@ func (server *Server) handleInvited(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, server.config.ExternalAddress+"signup?"+params.Encode(), http.StatusTemporaryRedirect) } +// graphqlHandler is graphql endpoint http handler function. +func (server *Server) graphqlHandler(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + defer mon.Task()(&ctx)(nil) + + handleError := func(code int, err error) { + w.WriteHeader(code) + + var jsonError struct { + Error string `json:"error"` + RequestID string `json:"requestID"` + } + + jsonError.Error = err.Error() + + if requestID := requestid.FromContext(ctx); requestID != "" { + jsonError.RequestID = requestID + } + + if err := json.NewEncoder(w).Encode(jsonError); err != nil { + server.log.Error("error graphql error", zap.Error(err)) + } + } + + w.Header().Set(contentType, applicationJSON) + + query, err := getQuery(w, r) + if err != nil { + handleError(http.StatusBadRequest, err) + return + } + + rootObject := make(map[string]interface{}) + + rootObject["origin"] = server.config.ExternalAddress + rootObject[consoleql.ActivationPath] = "activation?token=" + rootObject[consoleql.PasswordRecoveryPath] = "password-recovery?token=" + rootObject[consoleql.CancelPasswordRecoveryPath] = "cancel-password-recovery?token=" + rootObject[consoleql.SignInPath] = "login" + rootObject[consoleql.LetUsKnowURL] = server.config.LetUsKnowURL + rootObject[consoleql.ContactInfoURL] = server.config.ContactInfoURL + rootObject[consoleql.TermsAndConditionsURL] = server.config.TermsAndConditionsURL + rootObject[consoleql.SatelliteRegion] = server.config.SatelliteName + + result := graphql.Do(graphql.Params{ + Schema: server.schema, + Context: ctx, + RequestString: query.Query, + VariableValues: query.Variables, + OperationName: query.OperationName, + RootObject: rootObject, + }) + + getGqlError := func(err gqlerrors.FormattedError) error { + var gerr *gqlerrors.Error + if errors.As(err.OriginalError(), &gerr) { + return gerr.OriginalError + } + return nil + } + + parseConsoleError := func(err error) (int, error) { + switch { + case console.ErrUnauthorized.Has(err): + return http.StatusUnauthorized, err + case console.Error.Has(err): + return http.StatusInternalServerError, err + } + + return 0, nil + } + + handleErrors := func(code int, errors gqlerrors.FormattedErrors) { + w.WriteHeader(code) + + var jsonError struct { + Errors []string `json:"errors"` + RequestID string `json:"requestID"` + } + + for _, err := range errors { + jsonError.Errors = append(jsonError.Errors, err.Message) + } + + if requestID := requestid.FromContext(ctx); requestID != "" { + jsonError.RequestID = requestID + } + + if err := json.NewEncoder(w).Encode(jsonError); err != nil { + server.log.Error("error graphql error", zap.Error(err)) + } + } + + handleGraphqlErrors := func() { + for _, err := range result.Errors { + gqlErr := getGqlError(err) + if gqlErr == nil { + continue + } + + code, err := parseConsoleError(gqlErr) + if err != nil { + handleError(code, err) + return + } + } + + handleErrors(http.StatusOK, result.Errors) + } + + if result.HasErrors() { + handleGraphqlErrors() + return + } + + err = json.NewEncoder(w).Encode(result) + if err != nil { + server.log.Error("error encoding grapql result", zap.Error(err)) + return + } + + server.log.Debug(fmt.Sprintf("%s", result)) +} + // serveError serves a static error page. func (server *Server) serveError(w http.ResponseWriter, status int) { w.WriteHeader(status) diff --git a/satellite/console/consoleweb/utils.go b/satellite/console/consoleweb/utils.go index 8c45e3b9c..9d9c3f9ec 100644 --- a/satellite/console/consoleweb/utils.go +++ b/satellite/console/consoleweb/utils.go @@ -4,13 +4,18 @@ package consoleweb import ( + "encoding/json" "fmt" + "io" "mime" "net/http" "regexp" "strings" + "github.com/zeebo/errs" + "storj.io/common/memory" + "storj.io/storj/satellite/console/consoleweb/consoleql" ) // ContentLengthLimit describes 4KB limit. @@ -28,6 +33,42 @@ func init() { } } +// JSON request from graphql clients. +type graphqlJSON struct { + Query string + OperationName string + Variables map[string]interface{} +} + +// getQuery retrieves graphql query from request. +func getQuery(w http.ResponseWriter, req *http.Request) (query graphqlJSON, err error) { + switch req.Method { + case http.MethodGet: + query.Query = req.URL.Query().Get(consoleql.Query) + return query, nil + case http.MethodPost: + return queryPOST(w, req) + default: + return query, errs.New("wrong http request type") + } +} + +// queryPOST retrieves graphql query from POST request. +func queryPOST(w http.ResponseWriter, req *http.Request) (query graphqlJSON, err error) { + limitedReader := http.MaxBytesReader(w, req.Body, ContentLengthLimit.Int64()) + switch typ := req.Header.Get(contentType); typ { + case applicationGraphql: + body, err := io.ReadAll(limitedReader) + query.Query = string(body) + return query, errs.Combine(err, limitedReader.Close()) + case applicationJSON: + err := json.NewDecoder(limitedReader).Decode(&query) + return query, errs.Combine(err, limitedReader.Close()) + default: + return query, errs.New("can't parse request body of type %s", typ) + } +} + // getClientIPRegExp is used by the function getClientIP. var getClientIPRegExp = regexp.MustCompile(`(?i:(?:^|;)for=([^,; ]+))`) diff --git a/testsuite/storjscan/go.mod b/testsuite/storjscan/go.mod index a6439f5f0..b6190444c 100644 --- a/testsuite/storjscan/go.mod +++ b/testsuite/storjscan/go.mod @@ -56,6 +56,7 @@ require ( github.com/gorilla/schema v1.2.0 // indirect github.com/gorilla/websocket v1.4.2 // indirect github.com/graph-gophers/graphql-go v1.3.0 // indirect + github.com/graphql-go/graphql v0.7.9 // indirect github.com/hashicorp/go-bexpr v0.1.10 // indirect github.com/hashicorp/golang-lru v0.5.5-0.20210104140557-80c98217689d // indirect github.com/hashicorp/hcl v1.0.0 // indirect diff --git a/testsuite/storjscan/go.sum b/testsuite/storjscan/go.sum index 21cc51483..a4d1e6e54 100644 --- a/testsuite/storjscan/go.sum +++ b/testsuite/storjscan/go.sum @@ -320,6 +320,8 @@ github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/graph-gophers/graphql-go v0.0.0-20201113091052-beb923fada29/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= github.com/graph-gophers/graphql-go v1.3.0 h1:Eb9x/q6MFpCLz7jBCiP/WTxjSDrYLR1QY41SORZyNJ0= github.com/graph-gophers/graphql-go v1.3.0/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= +github.com/graphql-go/graphql v0.7.9 h1:5Va/Rt4l5g3YjwDnid3vFfn43faaQBq7rMcIZ0VnV34= +github.com/graphql-go/graphql v0.7.9/go.mod h1:k6yrAYQaSP59DC5UVxbgxESlmVyojThKdORUqGDGmrI= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= diff --git a/testsuite/ui/go.mod b/testsuite/ui/go.mod index e48dcab21..6daf081f2 100644 --- a/testsuite/ui/go.mod +++ b/testsuite/ui/go.mod @@ -81,6 +81,7 @@ require ( github.com/gorilla/mux v1.8.0 // indirect github.com/gorilla/schema v1.2.0 // indirect github.com/gorilla/websocket v1.4.2 // indirect + github.com/graphql-go/graphql v0.7.9 // indirect github.com/hashicorp/go-uuid v1.0.2 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect diff --git a/testsuite/ui/go.sum b/testsuite/ui/go.sum index cdb87cb18..2a3cc5e4b 100644 --- a/testsuite/ui/go.sum +++ b/testsuite/ui/go.sum @@ -570,6 +570,8 @@ github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlI github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/graphql-go/graphql v0.7.9 h1:5Va/Rt4l5g3YjwDnid3vFfn43faaQBq7rMcIZ0VnV34= +github.com/graphql-go/graphql v0.7.9/go.mod h1:k6yrAYQaSP59DC5UVxbgxESlmVyojThKdORUqGDGmrI= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=