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=