From 934e0c409c18c69bc399622cc4bb67809009e560 Mon Sep 17 00:00:00 2001 From: Khairul Hidayat Date: Sun, 18 Aug 2024 22:57:08 +0700 Subject: [PATCH] feat: add bucket object browser --- backend/go.mod | 19 +- backend/go.sum | 28 +++ backend/main.go | 6 +- backend/router/browse.go | 190 ++++++++++++++++++ backend/router/buckets.go | 4 +- backend/router/config.go | 4 +- backend/router/router.go | 21 ++ backend/schema/browse.go | 16 ++ backend/schema/bucket.go | 1 + backend/utils/cache.go | 49 +++++ backend/utils/garage.go | 17 ++ backend/utils/utils.go | 5 + package.json | 1 + pnpm-lock.yaml | 8 + src/lib/api.ts | 4 +- src/lib/utils.ts | 5 + src/pages/buckets/components/bucket-card.tsx | 4 +- .../buckets/manage/browse/browse-tab.tsx | 74 +++++++ src/pages/buckets/manage/browse/hooks.ts | 14 ++ .../manage/browse/object-list-navigator.tsx | 102 ++++++++++ .../buckets/manage/browse/object-list.tsx | 66 ++++++ src/pages/buckets/manage/browse/types.ts | 18 ++ .../overview-aliases.tsx | 0 .../overview-quota.tsx | 0 .../{components => overview}/overview-tab.tsx | 0 .../overview-website-access.tsx | 0 src/pages/buckets/manage/page.tsx | 18 +- .../allow-key-dialog.tsx | 0 .../permissions-tab.tsx | 0 29 files changed, 656 insertions(+), 18 deletions(-) create mode 100644 backend/router/browse.go create mode 100644 backend/router/router.go create mode 100644 backend/schema/browse.go create mode 100644 backend/utils/cache.go create mode 100644 src/pages/buckets/manage/browse/browse-tab.tsx create mode 100644 src/pages/buckets/manage/browse/hooks.ts create mode 100644 src/pages/buckets/manage/browse/object-list-navigator.tsx create mode 100644 src/pages/buckets/manage/browse/object-list.tsx create mode 100644 src/pages/buckets/manage/browse/types.ts rename src/pages/buckets/manage/{components => overview}/overview-aliases.tsx (100%) rename src/pages/buckets/manage/{components => overview}/overview-quota.tsx (100%) rename src/pages/buckets/manage/{components => overview}/overview-tab.tsx (100%) rename src/pages/buckets/manage/{components => overview}/overview-website-access.tsx (100%) rename src/pages/buckets/manage/{components => permissions}/allow-key-dialog.tsx (100%) rename src/pages/buckets/manage/{components => permissions}/permissions-tab.tsx (100%) diff --git a/backend/go.mod b/backend/go.mod index 67b5d32..c6aa4de 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,6 +3,21 @@ module khairul169/garage-webui go 1.22.5 require ( - github.com/joho/godotenv v1.5.1 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/aws/aws-sdk-go-v2 v1.30.4 + github.com/aws/aws-sdk-go-v2/credentials v1.17.28 + github.com/aws/aws-sdk-go-v2/service/s3 v1.59.0 + github.com/aws/smithy-go v1.20.4 + github.com/joho/godotenv v1.5.1 + github.com/pelletier/go-toml/v2 v2.2.2 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 677be4e..345e5aa 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,9 +1,35 @@ +github.com/aws/aws-sdk-go-v2 v1.30.4 h1:frhcagrVNrzmT95RJImMHgabt99vkXGslubDaDagTk8= +github.com/aws/aws-sdk-go-v2 v1.30.4/go.mod h1:CT+ZPWXbYrci8chcARI3OmI/qgd+f6WtuLOoaIA8PR0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4 h1:70PVAiL15/aBMh5LThwgXdSQorVr91L127ttckI9QQU= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.4/go.mod h1:/MQxMqci8tlqDH+pjmoLu1i0tbWCUP1hhyMRuFxpQCw= +github.com/aws/aws-sdk-go-v2/credentials v1.17.28 h1:m8+AHY/ND8CMHJnPoH7PJIRakWGa4gbfbxuY9TGTUXM= +github.com/aws/aws-sdk-go-v2/credentials v1.17.28/go.mod h1:6TF7dSc78ehD1SL6KpRIPKMA1GyyWflIkjqg+qmf4+c= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16 h1:TNyt/+X43KJ9IJJMjKfa3bNTiZbUP7DeCxfbTROESwY= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.16/go.mod h1:2DwJF39FlNAUiX5pAc0UNeiz16lK2t7IaFcm0LFHEgc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16 h1:jYfy8UPmd+6kJW5YhY0L1/KftReOGxI/4NtVSTh9O/I= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.16/go.mod h1:7ZfEPZxkW42Afq4uQB8H2E2e6ebh6mXTueEpYzjCzcs= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16 h1:mimdLQkIX1zr8GIPY1ZtALdBQGxcASiBd2MOp8m/dMc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.16/go.mod h1:YHk6owoSwrIsok+cAH9PENCOGoH5PU2EllX4vLtSrsY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4 h1:KypMCbLPPHEmf9DgMGw51jMj77VfGPAN2Kv4cfhlfgI= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.4/go.mod h1:Vz1JQXliGcQktFTN/LN6uGppAIRoLBR2bMvIMP0gOjc= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18 h1:GckUnpm4EJOAio1c8o25a+b3lVfwVzC9gnSBqiiNmZM= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.3.18/go.mod h1:Br6+bxfG33Dk3ynmkhsW2Z/t9D4+lRqdLDNCKi85w0U= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18 h1:tJ5RnkHCiSH0jyd6gROjlJtNwov0eGYNz8s8nFcR0jQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.18/go.mod h1:++NHzT+nAF7ZPrHPsA+ENvsXkOO8wEu+C6RXltAG4/c= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16 h1:jg16PhLPUiHIj8zYIW6bqzeQSuHVEiWnGA0Brz5Xv2I= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.17.16/go.mod h1:Uyk1zE1VVdsHSU7096h/rwnXDzOzYQVl+FNPhPw7ShY= +github.com/aws/aws-sdk-go-v2/service/s3 v1.59.0 h1:Cso4Ev/XauMVsbwdhYEoxg8rxZWw43CFqqaPB5w3W2c= +github.com/aws/aws-sdk-go-v2/service/s3 v1.59.0/go.mod h1:BSPI0EfnYUuNHPS0uqIo5VrRwzie+Fp+YhQOUs16sKI= +github.com/aws/smithy-go v1.20.4 h1:2HK1zBdPgRbjFOHlfeQZfpC4r72MOb9bZkiFwggKO+4= +github.com/aws/smithy-go v1.20.4/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= @@ -12,7 +38,9 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/main.go b/backend/main.go index 0dec923..6642233 100644 --- a/backend/main.go +++ b/backend/main.go @@ -13,15 +13,13 @@ import ( func main() { godotenv.Load() + utils.InitCacheManager() if err := utils.Garage.LoadConfig(); err != nil { log.Fatal("Failed to load config! ", err) } - http.HandleFunc("/api/config", router.GetConfig) - http.HandleFunc("/api/buckets", router.GetAllBuckets) - http.HandleFunc("/api/*", router.ProxyHandler) - + http.Handle("/api/", http.StripPrefix("/api", router.HandleApiRouter())) ui.ServeUI() host := utils.GetEnv("HOST", "0.0.0.0") diff --git a/backend/router/browse.go b/backend/router/browse.go new file mode 100644 index 0000000..2e70631 --- /dev/null +++ b/backend/router/browse.go @@ -0,0 +1,190 @@ +package router + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "khairul169/garage-webui/schema" + "khairul169/garage-webui/utils" + "net/http" + "strconv" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/smithy-go" +) + +type Browse struct{} + +func (b *Browse) GetObjects(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + bucket := r.PathValue("bucket") + prefix := query.Get("prefix") + continuationToken := query.Get("next") + + limit, err := strconv.Atoi(query.Get("limit")) + if err != nil { + limit = 100 + } + + client, err := getS3Client(bucket) + if err != nil { + utils.ResponseError(w, err) + return + } + + objects, err := client.ListObjectsV2(context.Background(), &s3.ListObjectsV2Input{ + Bucket: aws.String(bucket), + Prefix: aws.String(prefix), + Delimiter: aws.String("/"), + MaxKeys: aws.Int32(int32(limit)), + ContinuationToken: aws.String(continuationToken), + }) + + if err != nil { + utils.ResponseError(w, err) + return + } + + result := schema.BrowseObjectResult{ + Prefixes: []string{}, + Objects: []schema.BrowserObject{}, + Prefix: prefix, + NextToken: objects.NextContinuationToken, + } + + for _, prefix := range objects.CommonPrefixes { + result.Prefixes = append(result.Prefixes, *prefix.Prefix) + } + + for _, object := range objects.Contents { + key := strings.TrimPrefix(*object.Key, prefix) + if key == "" { + continue + } + + result.Objects = append(result.Objects, schema.BrowserObject{ + ObjectKey: &key, + LastModified: object.LastModified, + Size: object.Size, + }) + } + + utils.ResponseSuccess(w, result) +} + +func (b *Browse) GetOneObject(w http.ResponseWriter, r *http.Request) { + bucket := r.PathValue("bucket") + key := r.PathValue("key") + view := r.URL.Query().Get("view") == "1" + download := r.URL.Query().Get("dl") == "1" + + client, err := getS3Client(bucket) + if err != nil { + utils.ResponseError(w, err) + return + } + + object, err := client.GetObject(context.Background(), &s3.GetObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + }) + + if err != nil { + var ae smithy.APIError + if errors.As(err, &ae) && ae.ErrorCode() == "NoSuchKey" { + utils.ResponseErrorStatus(w, err, http.StatusNotFound) + return + } + + utils.ResponseError(w, err) + return + } + + if view || download { + defer object.Body.Close() + keys := strings.Split(key, "/") + + w.Header().Set("Content-Type", *object.ContentType) + if download { + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", keys[len(keys)-1])) + } + + w.WriteHeader(http.StatusOK) + _, err = io.Copy(w, object.Body) + + if err != nil { + utils.ResponseError(w, err) + return + } + return + } + + utils.ResponseSuccess(w, object) +} + +func getBucketCredentials(bucket string) (aws.CredentialsProvider, error) { + cacheKey := fmt.Sprintf("key:%s", bucket) + cacheData := utils.Cache.Get(cacheKey) + + if cacheData != nil { + return cacheData.(aws.CredentialsProvider), nil + } + + body, err := utils.Garage.Fetch("/v1/bucket?globalAlias="+bucket, &utils.FetchOptions{}) + if err != nil { + return nil, err + } + + var bucketData schema.Bucket + if err := json.Unmarshal(body, &bucketData); err != nil { + return nil, err + } + + var key schema.KeyElement + + for _, k := range bucketData.Keys { + if !k.Permissions.Read || !k.Permissions.Write { + continue + } + + body, err := utils.Garage.Fetch(fmt.Sprintf("/v1/key?id=%s&showSecretKey=true", k.AccessKeyID), &utils.FetchOptions{}) + if err != nil { + return nil, err + } + if err := json.Unmarshal(body, &key); err != nil { + return nil, err + } + break + } + + credential := credentials.NewStaticCredentialsProvider(key.AccessKeyID, key.SecretAccessKey, "") + utils.Cache.Set(cacheKey, credential, time.Hour) + + return credential, nil +} + +func getS3Client(bucket string) (*s3.Client, error) { + creds, err := getBucketCredentials(bucket) + if err != nil { + return nil, fmt.Errorf("cannot get credentials for bucket %s: %w", bucket, err) + } + + awsConfig := aws.Config{ + Credentials: creds, + Region: "garage", + BaseEndpoint: aws.String(utils.Garage.GetS3Endpoint()), + } + + client := s3.NewFromConfig(awsConfig, func(o *s3.Options) { + o.UsePathStyle = true + o.EndpointOptions.DisableHTTPS = true + }) + + return client, nil +} diff --git a/backend/router/buckets.go b/backend/router/buckets.go index ac33bb1..7df76f6 100644 --- a/backend/router/buckets.go +++ b/backend/router/buckets.go @@ -8,7 +8,9 @@ import ( "net/http" ) -func GetAllBuckets(w http.ResponseWriter, r *http.Request) { +type Buckets struct{} + +func (b *Buckets) GetAll(w http.ResponseWriter, r *http.Request) { body, err := utils.Garage.Fetch("/v1/bucket?list", &utils.FetchOptions{}) if err != nil { utils.ResponseError(w, err) diff --git a/backend/router/config.go b/backend/router/config.go index a61cca4..aca0155 100644 --- a/backend/router/config.go +++ b/backend/router/config.go @@ -5,7 +5,9 @@ import ( "net/http" ) -func GetConfig(w http.ResponseWriter, r *http.Request) { +type Config struct{} + +func (c *Config) GetAll(w http.ResponseWriter, r *http.Request) { config := utils.Garage.Config utils.ResponseSuccess(w, config) } diff --git a/backend/router/router.go b/backend/router/router.go new file mode 100644 index 0000000..b9282af --- /dev/null +++ b/backend/router/router.go @@ -0,0 +1,21 @@ +package router + +import "net/http" + +func HandleApiRouter() *http.ServeMux { + router := http.NewServeMux() + + config := &Config{} + router.HandleFunc("GET /config", config.GetAll) + + buckets := &Buckets{} + router.HandleFunc("GET /buckets", buckets.GetAll) + + browse := &Browse{} + router.HandleFunc("GET /browse/{bucket}", browse.GetObjects) + router.HandleFunc("GET /browse/{bucket}/{key...}", browse.GetOneObject) + + router.HandleFunc("/", ProxyHandler) + + return router +} diff --git a/backend/schema/browse.go b/backend/schema/browse.go new file mode 100644 index 0000000..3eb7941 --- /dev/null +++ b/backend/schema/browse.go @@ -0,0 +1,16 @@ +package schema + +import "time" + +type BrowseObjectResult struct { + Prefixes []string `json:"prefixes"` + Objects []BrowserObject `json:"objects"` + Prefix string `json:"prefix"` + NextToken *string `json:"nextToken"` +} + +type BrowserObject struct { + ObjectKey *string `json:"objectKey"` + LastModified *time.Time `json:"lastModified"` + Size *int64 `json:"size"` +} diff --git a/backend/schema/bucket.go b/backend/schema/bucket.go index 1046da3..9ce8b6a 100644 --- a/backend/schema/bucket.go +++ b/backend/schema/bucket.go @@ -26,6 +26,7 @@ type KeyElement struct { Name string `json:"name"` Permissions Permissions `json:"permissions"` BucketLocalAliases []interface{} `json:"bucketLocalAliases"` + SecretAccessKey string `json:"secretAccessKey"` } type Permissions struct { diff --git a/backend/utils/cache.go b/backend/utils/cache.go new file mode 100644 index 0000000..d38c441 --- /dev/null +++ b/backend/utils/cache.go @@ -0,0 +1,49 @@ +package utils + +import ( + "sync" + "time" +) + +type CacheEntry struct { + value interface{} + expiresAt time.Time +} + +type CacheManager struct { + cache *sync.Map +} + +var Cache *CacheManager + +func InitCacheManager() { + Cache = &CacheManager{ + cache: &sync.Map{}, + } +} + +func (c *CacheManager) Set(key string, value interface{}, ttl time.Duration) { + c.cache.Store(key, CacheEntry{ + value: value, + expiresAt: time.Now().Add(ttl), + }) +} + +func (c *CacheManager) Get(key string) interface{} { + entry, ok := c.cache.Load(key) + if !ok { + return nil + } + + cacheEntry := entry.(CacheEntry) + if cacheEntry.expiresAt.Before(time.Now()) { + c.cache.Delete(key) + return nil + } + + return cacheEntry.value +} + +func (c *CacheManager) IsExpired(entry CacheEntry) bool { + return entry.expiresAt.Before(time.Now()) +} diff --git a/backend/utils/garage.go b/backend/utils/garage.go index 2a8b28c..9d620e0 100644 --- a/backend/utils/garage.go +++ b/backend/utils/garage.go @@ -56,6 +56,23 @@ func (g *garage) GetAdminEndpoint() string { return endpoint } +func (g *garage) GetS3Endpoint() string { + endpoint := os.Getenv("S3_ENDPOINT_URL") + if len(endpoint) > 0 { + return endpoint + } + + host := strings.Split(g.Config.RPCPublicAddr, ":")[0] + port := LastString(strings.Split(g.Config.S3API.APIBindAddr, ":")) + + endpoint = fmt.Sprintf("%s:%s", host, port) + if !strings.HasPrefix(endpoint, "http") { + endpoint = fmt.Sprintf("http://%s", endpoint) + } + + return endpoint +} + func (g *garage) GetAdminKey() string { key := os.Getenv("API_ADMIN_KEY") if len(key) > 0 { diff --git a/backend/utils/utils.go b/backend/utils/utils.go index 7f08b0c..30c6c6d 100644 --- a/backend/utils/utils.go +++ b/backend/utils/utils.go @@ -23,6 +23,11 @@ func ResponseError(w http.ResponseWriter, err error) { w.Write([]byte(err.Error())) } +func ResponseErrorStatus(w http.ResponseWriter, err error, status int) { + w.WriteHeader(status) + w.Write([]byte(err.Error())) +} + func ResponseSuccess(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) diff --git a/package.json b/package.json index 15c27f4..a447946 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@hookform/resolvers": "^3.9.0", "@tanstack/react-query": "^5.51.23", "clsx": "^2.1.1", + "dayjs": "^1.11.12", "lucide-react": "^0.427.0", "react": "^18.3.1", "react-daisyui": "^5.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6f3db3..b0818b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + dayjs: + specifier: ^1.11.12 + version: 1.11.12 lucide-react: specifier: ^0.427.0 version: 0.427.0(react@18.3.1) @@ -842,6 +845,9 @@ packages: resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} engines: {node: '>=0.11'} + dayjs@1.11.12: + resolution: {integrity: sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==} + debug@4.3.6: resolution: {integrity: sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==} engines: {node: '>=6.0'} @@ -2395,6 +2401,8 @@ snapshots: dependencies: '@babel/runtime': 7.25.0 + dayjs@1.11.12: {} + debug@4.3.6: dependencies: ms: 2.1.2 diff --git a/src/lib/api.ts b/src/lib/api.ts index 6330b18..a700823 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -4,10 +4,12 @@ type FetchOptions = Omit & { body?: any; }; +export const API_URL = "/api"; + const api = { async fetch(url: string, options?: Partial) { const headers: Record = {}; - const _url = new URL("/api" + url, window.location.origin); + const _url = new URL(API_URL + url, window.location.origin); if (options?.params) { Object.entries(options.params).forEach(([key, value]) => { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ee82a1d..ff7a92d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,11 @@ import clsx from "clsx"; import { toast } from "sonner"; import { twMerge } from "tailwind-merge"; +import dayjsRelativeTime from "dayjs/plugin/relativeTime"; +import dayjs from "dayjs"; + +dayjs.extend(dayjsRelativeTime); +export { dayjs }; export const cn = (...args: any[]) => { return twMerge(clsx(...args)); diff --git a/src/pages/buckets/components/bucket-card.tsx b/src/pages/buckets/components/bucket-card.tsx index 7225a08..7867226 100644 --- a/src/pages/buckets/components/bucket-card.tsx +++ b/src/pages/buckets/components/bucket-card.tsx @@ -40,7 +40,9 @@ const BucketCard = ({ data }: Props) => {
- {/* */} +
); diff --git a/src/pages/buckets/manage/browse/browse-tab.tsx b/src/pages/buckets/manage/browse/browse-tab.tsx new file mode 100644 index 0000000..2e0fdce --- /dev/null +++ b/src/pages/buckets/manage/browse/browse-tab.tsx @@ -0,0 +1,74 @@ +import { useParams } from "react-router-dom"; +import { Card } from "react-daisyui"; + +import ObjectList from "./object-list"; +import { useBucket } from "../hooks"; +import { useState } from "react"; +import ObjectListNavigator from "./object-list-navigator"; +import { + EllipsisVertical, + FilePlus, + FolderPlus, + UploadIcon, +} from "lucide-react"; +import Button from "@/components/ui/button"; + +const BrowseTab = () => { + const { id } = useParams(); + const { data: bucket } = useBucket(id); + + const [curPrefix, setCurPrefix] = useState(-1); + const [prefixHistory, setPrefixHistory] = useState([]); + const bucketName = bucket?.globalAliases[0]; + + const gotoPrefix = (prefix: string) => { + const history = prefixHistory.slice(0, curPrefix + 1); + setPrefixHistory([...history, prefix]); + setCurPrefix(history.length); + }; + + if (!bucket) { + return null; + } + + if (!bucket.keys.find((k) => k.permissions.read && k.permissions.write)) { + return ( +
+

+ You need to add a key to your bucket to be able to browse it. +

+
+ ); + } + + return ( +
+ + +
+ ); +}; + +export default BrowseTab; diff --git a/src/pages/buckets/manage/browse/hooks.ts b/src/pages/buckets/manage/browse/hooks.ts new file mode 100644 index 0000000..8b18f58 --- /dev/null +++ b/src/pages/buckets/manage/browse/hooks.ts @@ -0,0 +1,14 @@ +import api from "@/lib/api"; +import { useQuery } from "@tanstack/react-query"; +import { GetObjectsResult, UseBrowserObjectOptions } from "./types"; + +export const useBrowseObjects = ( + bucket: string, + options?: UseBrowserObjectOptions +) => { + return useQuery({ + queryKey: ["browse", bucket, options], + queryFn: () => + api.get(`/browse/${bucket}`, { params: options }), + }); +}; diff --git a/src/pages/buckets/manage/browse/object-list-navigator.tsx b/src/pages/buckets/manage/browse/object-list-navigator.tsx new file mode 100644 index 0000000..c3c5e43 --- /dev/null +++ b/src/pages/buckets/manage/browse/object-list-navigator.tsx @@ -0,0 +1,102 @@ +import Button from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { Fragment } from "react/jsx-runtime"; + +type Props = { + bucketName?: string; + curPrefix: number; + setCurPrefix: React.Dispatch>; + prefixHistory: string[]; + actions?: React.ReactNode; +}; + +const ObjectListNavigator = ({ + bucketName, + curPrefix, + setCurPrefix, + prefixHistory, + actions, +}: Props) => { + const onGoBack = () => { + if (curPrefix >= 0) setCurPrefix(curPrefix - 1); + }; + + const onGoForward = () => { + if (curPrefix < prefixHistory.length - 1) setCurPrefix(curPrefix + 1); + }; + + return ( +
+
+
+ +
+ setCurPrefix(-1)} + /> + + {prefixHistory.map((prefix, i) => ( + + + setCurPrefix(i)} + /> + + ))} +
+ +
+ {actions} +
+
+ ); +}; + +type HistoryItemProps = { + title?: string; + isActive: boolean; + onClick: () => void; +}; + +const HistoryItem = ({ title, isActive, onClick }: HistoryItemProps) => { + if (!title) { + return null; + } + + return ( + { + e.preventDefault(); + onClick(); + }} + className={cn("px-2 rounded-sm shrink-0", isActive && "bg-neutral")} + > + {title} + + ); +}; + +export default ObjectListNavigator; diff --git a/src/pages/buckets/manage/browse/object-list.tsx b/src/pages/buckets/manage/browse/object-list.tsx new file mode 100644 index 0000000..ebecea3 --- /dev/null +++ b/src/pages/buckets/manage/browse/object-list.tsx @@ -0,0 +1,66 @@ +import { Table } from "react-daisyui"; +import { useBrowseObjects } from "./hooks"; +import { dayjs, readableBytes } from "@/lib/utils"; +import { Object } from "./types"; +import { API_URL } from "@/lib/api"; + +type Props = { + bucket: string; + prefix?: string; + onPrefixChange?: (prefix: string) => void; +}; + +const ObjectList = ({ bucket, prefix, onPrefixChange }: Props) => { + const { data } = useBrowseObjects(bucket, { prefix }); + + const onObjectClick = (object: Object) => { + window.open( + API_URL + `/browse/${bucket}/${data?.prefix}${object.objectKey}?view=1`, + "_blank" + ); + }; + + return ( +
+ + + Name + Size + Last Modified + + + + {data?.prefixes.map((prefix) => ( + onPrefixChange?.(prefix)} + > + + {prefix.substring(0, prefix.lastIndexOf("/")).split("/").pop()} + + + + + ))} + + {data?.objects.map((object) => ( + onObjectClick(object)} + > + {object.objectKey} + {readableBytes(object.size)} + {dayjs(object.lastModified).fromNow()} + + ))} + +
+
+ ); +}; + +export default ObjectList; diff --git a/src/pages/buckets/manage/browse/types.ts b/src/pages/buckets/manage/browse/types.ts new file mode 100644 index 0000000..1dd36f0 --- /dev/null +++ b/src/pages/buckets/manage/browse/types.ts @@ -0,0 +1,18 @@ +export type UseBrowserObjectOptions = Partial<{ + prefix: string; + limit: number; + next: string; +}>; + +export type GetObjectsResult = { + prefixes: string[]; + objects: Object[]; + prefix: string; + nextToken: string | null; +}; + +export type Object = { + objectKey: string; + lastModified: Date; + size: number; +}; diff --git a/src/pages/buckets/manage/components/overview-aliases.tsx b/src/pages/buckets/manage/overview/overview-aliases.tsx similarity index 100% rename from src/pages/buckets/manage/components/overview-aliases.tsx rename to src/pages/buckets/manage/overview/overview-aliases.tsx diff --git a/src/pages/buckets/manage/components/overview-quota.tsx b/src/pages/buckets/manage/overview/overview-quota.tsx similarity index 100% rename from src/pages/buckets/manage/components/overview-quota.tsx rename to src/pages/buckets/manage/overview/overview-quota.tsx diff --git a/src/pages/buckets/manage/components/overview-tab.tsx b/src/pages/buckets/manage/overview/overview-tab.tsx similarity index 100% rename from src/pages/buckets/manage/components/overview-tab.tsx rename to src/pages/buckets/manage/overview/overview-tab.tsx diff --git a/src/pages/buckets/manage/components/overview-website-access.tsx b/src/pages/buckets/manage/overview/overview-website-access.tsx similarity index 100% rename from src/pages/buckets/manage/components/overview-website-access.tsx rename to src/pages/buckets/manage/overview/overview-website-access.tsx diff --git a/src/pages/buckets/manage/page.tsx b/src/pages/buckets/manage/page.tsx index 172620b..4695d41 100644 --- a/src/pages/buckets/manage/page.tsx +++ b/src/pages/buckets/manage/page.tsx @@ -2,10 +2,11 @@ import { useParams } from "react-router-dom"; import { useBucket } from "./hooks"; import Page from "@/context/page-context"; import TabView, { Tab } from "@/components/containers/tab-view"; -import { ChartLine, LockKeyhole } from "lucide-react"; -import OverviewTab from "./components/overview-tab"; -import PermissionsTab from "./components/permissions-tab"; +import { ChartLine, FolderSearch, LockKeyhole } from "lucide-react"; +import OverviewTab from "./overview/overview-tab"; +import PermissionsTab from "./permissions/permissions-tab"; import MenuButton from "./components/menu-button"; +import BrowseTab from "./browse/browse-tab"; const tabs: Tab[] = [ { @@ -20,11 +21,12 @@ const tabs: Tab[] = [ icon: LockKeyhole, Component: PermissionsTab, }, - // { - // name: "browse", - // title: "Browse", - // icon: FolderSearch, - // }, + { + name: "browse", + title: "Browse", + icon: FolderSearch, + Component: BrowseTab, + }, ]; const ManageBucketPage = () => { diff --git a/src/pages/buckets/manage/components/allow-key-dialog.tsx b/src/pages/buckets/manage/permissions/allow-key-dialog.tsx similarity index 100% rename from src/pages/buckets/manage/components/allow-key-dialog.tsx rename to src/pages/buckets/manage/permissions/allow-key-dialog.tsx diff --git a/src/pages/buckets/manage/components/permissions-tab.tsx b/src/pages/buckets/manage/permissions/permissions-tab.tsx similarity index 100% rename from src/pages/buckets/manage/components/permissions-tab.tsx rename to src/pages/buckets/manage/permissions/permissions-tab.tsx