feat: add bucket object browser

This commit is contained in:
2024-08-18 22:57:08 +07:00
parent 3a147f4133
commit 934e0c409c
29 changed files with 656 additions and 18 deletions
+17 -2
View File
@@ -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
)
+28
View File
@@ -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=
+2 -4
View File
@@ -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
View 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
}
+3 -1
View File
@@ -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)
+3 -1
View File
@@ -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
View 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
View 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"`
}
+1
View File
@@ -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
View 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())
}
+17
View File
@@ -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 {
+5
View File
@@ -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)