mirror of
https://github.com/khairul169/garage-webui.git
synced 2025-04-27 22:39:31 +07:00
feat: add bucket object browser
This commit is contained in:
parent
3a147f4133
commit
934e0c409c
@ -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
|
||||
)
|
||||
|
@ -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=
|
||||
|
@ -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")
|
||||
|
190
backend/router/browse.go
Normal file
190
backend/router/browse.go
Normal file
@ -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
|
||||
}
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
21
backend/router/router.go
Normal file
21
backend/router/router.go
Normal file
@ -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
|
||||
}
|
16
backend/schema/browse.go
Normal file
16
backend/schema/browse.go
Normal file
@ -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"`
|
||||
}
|
@ -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 {
|
||||
|
49
backend/utils/cache.go
Normal file
49
backend/utils/cache.go
Normal file
@ -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())
|
||||
}
|
@ -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 {
|
||||
|
@ -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)
|
||||
|
@ -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",
|
||||
|
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -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
|
||||
|
@ -4,10 +4,12 @@ type FetchOptions = Omit<RequestInit, "headers" | "body"> & {
|
||||
body?: any;
|
||||
};
|
||||
|
||||
export const API_URL = "/api";
|
||||
|
||||
const api = {
|
||||
async fetch<T = any>(url: string, options?: Partial<FetchOptions>) {
|
||||
const headers: Record<string, string> = {};
|
||||
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]) => {
|
||||
|
@ -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));
|
||||
|
@ -40,7 +40,9 @@ const BucketCard = ({ data }: Props) => {
|
||||
|
||||
<div className="flex flex-row justify-end gap-4">
|
||||
<Button href={`/buckets/${data.id}`}>Manage</Button>
|
||||
{/* <Button color="primary">Browse</Button> */}
|
||||
<Button color="primary" href={`/buckets/${data.id}?tab=browse`}>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
74
src/pages/buckets/manage/browse/browse-tab.tsx
Normal file
74
src/pages/buckets/manage/browse/browse-tab.tsx
Normal file
@ -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<string[]>([]);
|
||||
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 (
|
||||
<div className="p-4 min-h-[200px] flex flex-col justify-center">
|
||||
<p className="text-center">
|
||||
You need to add a key to your bucket to be able to browse it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<ObjectListNavigator
|
||||
bucketName={bucketName}
|
||||
curPrefix={curPrefix}
|
||||
setCurPrefix={setCurPrefix}
|
||||
prefixHistory={prefixHistory}
|
||||
actions={
|
||||
<>
|
||||
<Button icon={FolderPlus} color="ghost" />
|
||||
<Button icon={FilePlus} color="ghost" />
|
||||
<Button icon={UploadIcon} color="ghost" />
|
||||
<Button icon={EllipsisVertical} color="ghost" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{bucketName ? (
|
||||
<ObjectList
|
||||
bucket={bucketName}
|
||||
prefix={prefixHistory[curPrefix] || ""}
|
||||
onPrefixChange={gotoPrefix}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrowseTab;
|
14
src/pages/buckets/manage/browse/hooks.ts
Normal file
14
src/pages/buckets/manage/browse/hooks.ts
Normal file
@ -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<GetObjectsResult>(`/browse/${bucket}`, { params: options }),
|
||||
});
|
||||
};
|
102
src/pages/buckets/manage/browse/object-list-navigator.tsx
Normal file
102
src/pages/buckets/manage/browse/object-list-navigator.tsx
Normal file
@ -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<React.SetStateAction<number>>;
|
||||
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 (
|
||||
<div className="flex flex-row flex-wrap items-center p-2 gap-y-2">
|
||||
<div className="order-1 flex flex-row items-center">
|
||||
<Button
|
||||
icon={ChevronLeft}
|
||||
color="ghost"
|
||||
disabled={curPrefix < 0}
|
||||
onClick={onGoBack}
|
||||
className="col-span-2"
|
||||
/>
|
||||
<Button
|
||||
icon={ChevronRight}
|
||||
color="ghost"
|
||||
disabled={curPrefix >= prefixHistory.length - 1}
|
||||
onClick={onGoForward}
|
||||
className="col-span-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="order-3 md:order-2 flex flex-row w-full overflow-x-auto items-center bg-base-200 h-10 flex-1 shrink-0 min-w-[80%] md:min-w-0 rounded-lg mx-2 pl-4">
|
||||
<HistoryItem
|
||||
title={bucketName}
|
||||
isActive={curPrefix === -1}
|
||||
onClick={() => setCurPrefix(-1)}
|
||||
/>
|
||||
|
||||
{prefixHistory.map((prefix, i) => (
|
||||
<Fragment key={prefix}>
|
||||
<ChevronRight className="shrink-0" size={20} />
|
||||
<HistoryItem
|
||||
title={prefix
|
||||
.substring(0, prefix.lastIndexOf("/"))
|
||||
.split("/")
|
||||
.pop()}
|
||||
isActive={i === curPrefix}
|
||||
onClick={() => setCurPrefix(i)}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="order-2 flex flex-row items-center flex-1 md:order-3 md:flex-initial justify-end">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type HistoryItemProps = {
|
||||
title?: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const HistoryItem = ({ title, isActive, onClick }: HistoryItemProps) => {
|
||||
if (!title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}}
|
||||
className={cn("px-2 rounded-sm shrink-0", isActive && "bg-neutral")}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default ObjectListNavigator;
|
66
src/pages/buckets/manage/browse/object-list.tsx
Normal file
66
src/pages/buckets/manage/browse/object-list.tsx
Normal file
@ -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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<Table.Head>
|
||||
<span>Name</span>
|
||||
<span>Size</span>
|
||||
<span>Last Modified</span>
|
||||
</Table.Head>
|
||||
|
||||
<Table.Body>
|
||||
{data?.prefixes.map((prefix) => (
|
||||
<Table.Row
|
||||
key={prefix}
|
||||
className="hover:bg-neutral cursor-pointer"
|
||||
role="button"
|
||||
onClick={() => onPrefixChange?.(prefix)}
|
||||
>
|
||||
<span>
|
||||
{prefix.substring(0, prefix.lastIndexOf("/")).split("/").pop()}
|
||||
</span>
|
||||
<span />
|
||||
<span />
|
||||
</Table.Row>
|
||||
))}
|
||||
|
||||
{data?.objects.map((object) => (
|
||||
<Table.Row
|
||||
key={object.objectKey}
|
||||
className="hover:bg-neutral cursor-pointer"
|
||||
role="button"
|
||||
onClick={() => onObjectClick(object)}
|
||||
>
|
||||
<span>{object.objectKey}</span>
|
||||
<span>{readableBytes(object.size)}</span>
|
||||
<span>{dayjs(object.lastModified).fromNow()}</span>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ObjectList;
|
18
src/pages/buckets/manage/browse/types.ts
Normal file
18
src/pages/buckets/manage/browse/types.ts
Normal file
@ -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;
|
||||
};
|
@ -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 = () => {
|
||||
|
Loading…
x
Reference in New Issue
Block a user