diff --git a/satellite/accounting/projectusage_test.go b/satellite/accounting/projectusage_test.go index 714105012..f3497ef07 100644 --- a/satellite/accounting/projectusage_test.go +++ b/satellite/accounting/projectusage_test.go @@ -744,5 +744,4 @@ func TestProjectUsage_BandwidthDownloadLimit(t *testing.T) { _, err = planet.Uplinks[0].Download(ctx, planet.Satellites[0], "testbucket", "test/path1") require.NoError(t, err) }) - } diff --git a/satellite/console/consoleweb/consoleapi/buckets.go b/satellite/console/consoleweb/consoleapi/buckets.go new file mode 100644 index 000000000..5601336e1 --- /dev/null +++ b/satellite/console/consoleweb/consoleapi/buckets.go @@ -0,0 +1,89 @@ +// Copyright (C) 2020 Storj Labs, Inc. +// See LICENSE for copying information. + +package consoleapi + +import ( + "encoding/json" + "net/http" + + "github.com/zeebo/errs" + "go.uber.org/zap" + + "storj.io/common/uuid" + "storj.io/storj/satellite/console" +) + +var ( + // ErrBucketsAPI - console buckets api error type. + ErrBucketsAPI = errs.Class("console buckets api error") +) + +// Buckets is an api controller that exposes all buckets related functionality. +type Buckets struct { + log *zap.Logger + service *console.Service +} + +// NewBuckets is a constructor for api buckets controller. +func NewBuckets(log *zap.Logger, service *console.Service) *Buckets { + return &Buckets{ + log: log, + service: service, + } +} + +// AllBucketNames returns all bucket names for a specific project. +func (b *Buckets) AllBucketNames(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + var err error + defer mon.Task()(&ctx)(&err) + + w.Header().Set("Content-Type", "application/json") + + projectIDString := r.URL.Query().Get("projectID") + + projectID, err := uuid.FromString(projectIDString) + if err != nil { + b.serveJSONError(w, http.StatusInternalServerError, err) + return + } + + bucketNames, err := b.service.GetAllBucketNames(ctx, projectID) + if err != nil { + if console.ErrUnauthorized.Has(err) { + b.serveJSONError(w, http.StatusUnauthorized, err) + return + } + + b.serveJSONError(w, http.StatusInternalServerError, err) + return + } + + err = json.NewEncoder(w).Encode(bucketNames) + if err != nil { + b.log.Error("failed to write json all bucket names response", zap.Error(ErrBucketsAPI.Wrap(err))) + } +} + +// serveJSONError writes JSON error to response output stream. +func (b *Buckets) serveJSONError(w http.ResponseWriter, status int, err error) { + if status == http.StatusInternalServerError { + b.log.Error("returning error to client", zap.Int("code", status), zap.Error(err)) + } else { + b.log.Debug("returning error to client", zap.Int("code", status), zap.Error(err)) + } + + w.WriteHeader(status) + + var response struct { + Error string `json:"error"` + } + + response.Error = err.Error() + + err = json.NewEncoder(w).Encode(response) + if err != nil { + b.log.Error("failed to write json error response", zap.Error(ErrBucketsAPI.Wrap(err))) + } +} diff --git a/satellite/console/consoleweb/consoleapi/buckets_test.go b/satellite/console/consoleweb/consoleapi/buckets_test.go new file mode 100644 index 000000000..27b21e9df --- /dev/null +++ b/satellite/console/consoleweb/consoleapi/buckets_test.go @@ -0,0 +1,114 @@ +// Copyright (C) 2020 Storj Labs, Inc. +// See LICENSE for copying information. + +package consoleapi_test + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "storj.io/common/storj" + "storj.io/common/testcontext" + "storj.io/common/testrand" + "storj.io/storj/private/testplanet" + "storj.io/storj/satellite" + "storj.io/storj/satellite/console" +) + +func Test_AllBucketNames(t *testing.T) { + testplanet.Run(t, testplanet.Config{ + SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1, + Reconfigure: testplanet.Reconfigure{ + Satellite: func(log *zap.Logger, index int, config *satellite.Config) { + config.Console.OpenRegistrationEnabled = true + config.Console.RateLimit.Burst = 10 + }, + }, + }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { + sat := planet.Satellites[0] + project := planet.Uplinks[0].Projects[0] + service := sat.API.Console.Service + + bucket1 := storj.Bucket{ + ID: testrand.UUID(), + Name: "testBucket1", + ProjectID: project.ID, + } + + bucket2 := storj.Bucket{ + ID: testrand.UUID(), + Name: "testBucket2", + ProjectID: project.ID, + } + + _, err := sat.DB.Buckets().CreateBucket(ctx, bucket1) + require.NoError(t, err) + + _, err = sat.DB.Buckets().CreateBucket(ctx, bucket2) + require.NoError(t, err) + + user := console.CreateUser{ + FullName: "Jack", + ShortName: "", + Email: "bucketest@test.test", + Password: "123a123", + } + refUserID := "" + + regToken, err := service.CreateRegToken(ctx, 1) + require.NoError(t, err) + + createdUser, err := service.CreateUser(ctx, user, regToken.Secret, refUserID) + require.NoError(t, err) + + activationToken, err := service.GenerateActivationToken(ctx, createdUser.ID, createdUser.Email) + require.NoError(t, err) + + err = service.ActivateAccount(ctx, activationToken) + require.NoError(t, err) + + token, err := service.Token(ctx, user.Email, user.Password) + require.NoError(t, err) + + client := http.Client{} + + req, err := http.NewRequest("GET", "http://"+planet.Satellites[0].API.Console.Listener.Addr().String()+"/api/v0/buckets/bucket-names?projectID="+project.ID.String(), nil) + require.NoError(t, err) + + expire := time.Now().AddDate(0, 0, 1) + cookie := http.Cookie{ + Name: "_tokenKey", + Path: "/", + Value: token, + Expires: expire, + } + + req.AddCookie(&cookie) + + result, err := client.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, result.StatusCode) + + body, err := ioutil.ReadAll(result.Body) + require.NoError(t, err) + + var output []string + + err = json.Unmarshal(body, &output) + require.NoError(t, err) + + require.Equal(t, bucket1.Name, output[0]) + require.Equal(t, bucket2.Name, output[1]) + + defer func() { + err = result.Body.Close() + require.NoError(t, err) + }() + }) +} diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index 93f60ec9c..f620342ee 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -196,6 +196,11 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, mail paymentsRouter.HandleFunc("/tokens/deposit", paymentController.TokenDeposit).Methods(http.MethodPost) paymentsRouter.HandleFunc("/paywall-enabled/{userId}", paymentController.PaywallEnabled).Methods(http.MethodGet) + bucketsController := consoleapi.NewBuckets(logger, service) + bucketsRouter := router.PathPrefix("/api/v0/buckets").Subrouter() + bucketsRouter.Use(server.withAuth) + bucketsRouter.HandleFunc("/bucket-names", bucketsController.AllBucketNames).Methods(http.MethodGet) + if server.config.StaticDir != "" { router.HandleFunc("/activation/", server.accountActivationHandler) router.HandleFunc("/password-recovery/", server.passwordRecoveryHandler) diff --git a/satellite/console/service.go b/satellite/console/service.go index 60d63dea2..41fff244e 100644 --- a/satellite/console/service.go +++ b/satellite/console/service.go @@ -21,6 +21,7 @@ import ( "storj.io/common/macaroon" "storj.io/common/memory" + "storj.io/common/storj" "storj.io/common/uuid" "storj.io/storj/satellite/accounting" "storj.io/storj/satellite/console/consoleauth" @@ -1455,6 +1456,36 @@ func (s *Service) GetBucketTotals(ctx context.Context, projectID uuid.UUID, curs return usage, nil } +// GetAllBucketNames retrieves all bucket names of a specific project. +func (s *Service) GetAllBucketNames(ctx context.Context, projectID uuid.UUID) (_ []string, err error) { + defer mon.Task()(&ctx)(&err) + + _, err = s.getAuthAndAuditLog(ctx, "get all bucket names", zap.String("projectID", projectID.String())) + if err != nil { + return nil, Error.Wrap(err) + } + + listOptions := storj.BucketListOptions{ + Direction: storj.Forward, + } + + allowedBuckets := macaroon.AllowedBuckets{ + All: true, + } + + bucketsList, err := s.buckets.ListBuckets(ctx, projectID, listOptions, allowedBuckets) + if err != nil { + return nil, Error.Wrap(err) + } + + var list []string + for _, bucket := range bucketsList.Items { + list = append(list, bucket.Name) + } + + return list, nil +} + // GetBucketUsageRollups retrieves summed usage rollups for every bucket of particular project for a given period. func (s *Service) GetBucketUsageRollups(ctx context.Context, projectID uuid.UUID, since, before time.Time) (_ []accounting.BucketUsageRollup, err error) { defer mon.Task()(&ctx)(&err) diff --git a/satellite/console/service_test.go b/satellite/console/service_test.go index d0ecc8723..febf33000 100644 --- a/satellite/console/service_test.go +++ b/satellite/console/service_test.go @@ -8,7 +8,9 @@ import ( "github.com/stretchr/testify/require" + "storj.io/common/storj" "storj.io/common/testcontext" + "storj.io/common/testrand" "storj.io/common/uuid" "storj.io/storj/private/testplanet" "storj.io/storj/satellite/console" @@ -144,5 +146,30 @@ func TestService(t *testing.T) { err = service.ChangeEmail(authCtx2, newEmail) require.Error(t, err) }) + + t.Run("TestGetAllBucketNames", func(t *testing.T) { + bucket1 := storj.Bucket{ + ID: testrand.UUID(), + Name: "testBucket1", + ProjectID: up2Pro1.ID, + } + + bucket2 := storj.Bucket{ + ID: testrand.UUID(), + Name: "testBucket2", + ProjectID: up2Pro1.ID, + } + + _, err := sat.DB.Buckets().CreateBucket(authCtx1, bucket1) + require.NoError(t, err) + + _, err = sat.DB.Buckets().CreateBucket(authCtx1, bucket2) + require.NoError(t, err) + + bucketNames, err := service.GetAllBucketNames(authCtx1, up2Pro1.ID) + require.NoError(t, err) + require.Equal(t, bucket1.Name, bucketNames[0]) + require.Equal(t, bucket2.Name, bucketNames[1]) + }) }) } diff --git a/web/satellite/src/api/buckets.ts b/web/satellite/src/api/buckets.ts index 20173a09d..77daf93a0 100644 --- a/web/satellite/src/api/buckets.ts +++ b/web/satellite/src/api/buckets.ts @@ -2,13 +2,18 @@ // See LICENSE for copying information. import { BaseGql } from '@/api/baseGql'; +import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized'; import { Bucket, BucketCursor, BucketPage, BucketsApi } from '@/types/buckets'; +import { HttpClient } from '@/utils/httpClient'; /** * BucketsApiGql is a graphql implementation of Buckets API. * Exposes all bucket-related functionality. */ export class BucketsApiGql extends BaseGql implements BucketsApi { + private readonly client: HttpClient = new HttpClient(); + private readonly ROOT_PATH: string = '/api/v0/buckets'; + /** * Fetch buckets. * @@ -53,6 +58,27 @@ export class BucketsApiGql extends BaseGql implements BucketsApi { return this.getBucketPage(response.data.project.bucketUsages); } + /** + * Fetch all bucket names. + * + * @returns string[] + * @throws Error + */ + public async getAllBucketNames(projectId: string): Promise { + const path = `${this.ROOT_PATH}/bucket-names?projectID=${projectId}`; + const response = await this.client.get(path); + + if (!response.ok) { + if (response.status === 401) { + throw new ErrorUnauthorized(); + } + + throw new Error('Can not get account balance'); + } + + return await response.json(); + } + /** * Method for mapping buckets page from json to BucketPage type. * diff --git a/web/satellite/src/store/modules/buckets.ts b/web/satellite/src/store/modules/buckets.ts index d823a128a..86ed325b4 100644 --- a/web/satellite/src/store/modules/buckets.ts +++ b/web/satellite/src/store/modules/buckets.ts @@ -6,12 +6,14 @@ import { Bucket, BucketCursor, BucketPage, BucketsApi } from '@/types/buckets'; export const BUCKET_ACTIONS = { FETCH: 'setBuckets', + FETCH_ALL_BUCKET_NAMES: 'getAllBucketNames', SET_SEARCH: 'setBucketSearch', CLEAR: 'clearBuckets', }; export const BUCKET_MUTATIONS = { SET: 'setBuckets', + SET_ALL_BUCKET_NAMES: 'setAllBucketNames', SET_SEARCH: 'setBucketSearch', SET_PAGE: 'setBucketPage', CLEAR: 'clearBuckets', @@ -19,9 +21,11 @@ export const BUCKET_MUTATIONS = { const { FETCH, + FETCH_ALL_BUCKET_NAMES, } = BUCKET_ACTIONS; const { SET, + SET_ALL_BUCKET_NAMES, SET_PAGE, SET_SEARCH, CLEAR, @@ -30,6 +34,7 @@ const bucketPageLimit = 7; const firstPage = 1; export class BucketsState { + public allBucketNames: string[] = []; public cursor: BucketCursor = { limit: bucketPageLimit, search: '', page: firstPage }; public page: BucketPage = { buckets: new Array(), currentPage: 1, pageCount: 1, offset: 0, limit: bucketPageLimit, search: '', totalCount: 0 }; } @@ -47,6 +52,9 @@ export function makeBucketsModule(api: BucketsApi): StoreModule { [SET](state: BucketsState, page: BucketPage) { state.page = page; }, + [SET_ALL_BUCKET_NAMES](state: BucketsState, allBucketNames: string[]) { + state.allBucketNames = allBucketNames; + }, [SET_PAGE](state: BucketsState, page: number) { state.cursor.page = page; }, @@ -54,6 +62,7 @@ export function makeBucketsModule(api: BucketsApi): StoreModule { state.cursor.search = search; }, [CLEAR](state: BucketsState) { + state.allBucketNames = []; state.cursor = new BucketCursor('', bucketPageLimit, firstPage); state.page = new BucketPage([], '', bucketPageLimit, 0, 1, 1, 0); }, @@ -72,6 +81,13 @@ export function makeBucketsModule(api: BucketsApi): StoreModule { return result; }, + [FETCH_ALL_BUCKET_NAMES]: async function({commit, rootGetters}: any): Promise { + const result: string[] = await api.getAllBucketNames(rootGetters.selectedProject.id); + + commit(SET_ALL_BUCKET_NAMES, result); + + return result; + }, [BUCKET_ACTIONS.SET_SEARCH]: function({commit}, search: string) { commit(SET_SEARCH, search); }, diff --git a/web/satellite/src/types/buckets.ts b/web/satellite/src/types/buckets.ts index e6d01118b..827d59abf 100644 --- a/web/satellite/src/types/buckets.ts +++ b/web/satellite/src/types/buckets.ts @@ -12,6 +12,14 @@ export interface BucketsApi { * @throws Error */ get(projectId: string, before: Date, cursor: BucketCursor): Promise; + + /** + * Fetch all bucket names + * + * @returns string[] + * @throws Error + */ + getAllBucketNames(projectId: string): Promise; } /** diff --git a/web/satellite/tests/unit/mock/api/buckets.ts b/web/satellite/tests/unit/mock/api/buckets.ts index 6b1a26427..f628835d8 100644 --- a/web/satellite/tests/unit/mock/api/buckets.ts +++ b/web/satellite/tests/unit/mock/api/buckets.ts @@ -10,4 +10,8 @@ export class BucketsMock implements BucketsApi { get(projectId: string, before: Date, cursor: BucketCursor): Promise { return Promise.resolve(new BucketPage()); } + + getAllBucketNames(projectId: string): Promise { + return Promise.resolve(['test']); + } }