Add support for subdirectory

This commit is contained in:
Tri Vo 2023-06-13 22:37:57 +07:00 committed by Lena Fuhrimann
parent 79eccc5e95
commit 17280f9304
4 changed files with 104 additions and 23 deletions

View file

@ -6,32 +6,44 @@ import (
"io/fs" "io/fs"
"net/http" "net/http"
"path" "path"
"regexp"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7"
) )
// HandleBucketView shows the details page of a bucket. // HandleBucketView shows the details page of a bucket.
func HandleBucketView(s3 S3, templates fs.FS, allowDelete bool, listRecursive bool) http.HandlerFunc { func HandleBucketView(s3 S3, templates fs.FS, allowDelete bool, listRecursive bool) http.HandlerFunc {
type objectWithIcon struct { type objectWithIcon struct {
Info minio.ObjectInfo Key string
Size int64
LastModified time.Time
Owner string
Icon string Icon string
IsFolder bool
DisplayName string
} }
type pageData struct { type pageData struct {
BucketName string BucketName string
Objects []objectWithIcon Objects []objectWithIcon
AllowDelete bool AllowDelete bool
Paths []string
} }
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
bucketName := mux.Vars(r)["bucketName"] regex := regexp.MustCompile(`\/buckets\/([^\/]*)\/?(.*)`)
matches := regex.FindStringSubmatch(r.RequestURI)
bucketName := matches[1]
path := matches[2]
var objs []objectWithIcon var objs []objectWithIcon
doneCh := make(chan struct{}) doneCh := make(chan struct{})
defer close(doneCh) defer close(doneCh)
opts := minio.ListObjectsOptions{ opts := minio.ListObjectsOptions{
Recursive: listRecursive, Recursive: listRecursive,
Prefix: path,
} }
objectCh := s3.ListObjects(r.Context(), bucketName, opts) objectCh := s3.ListObjects(r.Context(), bucketName, opts)
for object := range objectCh { for object := range objectCh {
@ -39,13 +51,23 @@ func HandleBucketView(s3 S3, templates fs.FS, allowDelete bool, listRecursive bo
handleHTTPError(w, fmt.Errorf("error listing objects: %w", object.Err)) handleHTTPError(w, fmt.Errorf("error listing objects: %w", object.Err))
return return
} }
obj := objectWithIcon{Info: object, Icon: icon(object.Key)}
obj := objectWithIcon{
Key: object.Key,
Size: object.Size,
LastModified: object.LastModified,
Owner: object.Owner.DisplayName,
Icon: icon(object.Key),
IsFolder: strings.HasSuffix(object.Key, "/"),
DisplayName: strings.TrimSuffix(strings.TrimPrefix(object.Key, path), "/"),
}
objs = append(objs, obj) objs = append(objs, obj)
} }
data := pageData{ data := pageData{
BucketName: bucketName, BucketName: bucketName,
Objects: objs, Objects: objs,
AllowDelete: allowDelete, AllowDelete: allowDelete,
Paths: removeEmptyStrings(strings.Split(path, "/")),
} }
t, err := template.ParseFS(templates, "layout.html.tmpl", "bucket.html.tmpl") t, err := template.ParseFS(templates, "layout.html.tmpl", "bucket.html.tmpl")
@ -63,6 +85,10 @@ func HandleBucketView(s3 S3, templates fs.FS, allowDelete bool, listRecursive bo
// icon returns an icon for a file type. // icon returns an icon for a file type.
func icon(fileName string) string { func icon(fileName string) string {
if strings.HasSuffix(fileName, "/") {
return "folder"
}
e := path.Ext(fileName) e := path.Ext(fileName)
switch e { switch e {
case ".tgz", ".gz", ".zip": case ".tgz", ".gz", ".zip":
@ -75,3 +101,13 @@ func icon(fileName string) string {
return "insert_drive_file" return "insert_drive_file"
} }
func removeEmptyStrings(input []string) []string {
var result []string
for _, str := range input {
if str != "" {
result = append(result, str)
}
}
return result
}

View file

@ -25,6 +25,7 @@ func TestHandleBucketView(t *testing.T) {
it string it string
listObjectsFunc func(context.Context, string, minio.ListObjectsOptions) <-chan minio.ObjectInfo listObjectsFunc func(context.Context, string, minio.ListObjectsOptions) <-chan minio.ObjectInfo
bucketName string bucketName string
path string
expectedStatusCode int expectedStatusCode int
expectedBodyContains string expectedBodyContains string
}{ }{
@ -123,6 +124,32 @@ func TestHandleBucketView(t *testing.T) {
expectedStatusCode: http.StatusInternalServerError, expectedStatusCode: http.StatusInternalServerError,
expectedBodyContains: http.StatusText(http.StatusInternalServerError), expectedBodyContains: http.StatusText(http.StatusInternalServerError),
}, },
{
it: "renders a bucket with folder",
listObjectsFunc: func(context.Context, string, minio.ListObjectsOptions) <-chan minio.ObjectInfo {
objCh := make(chan minio.ObjectInfo)
go func() {
objCh <- minio.ObjectInfo{Key: "AFolder/"}
close(objCh)
}()
return objCh
},
bucketName: "BUCKET-NAME",
expectedStatusCode: http.StatusOK,
expectedBodyContains: "folder",
},
{
it: "renders a bucket with path",
listObjectsFunc: func(context.Context, string, minio.ListObjectsOptions) <-chan minio.ObjectInfo {
objCh := make(chan minio.ObjectInfo)
close(objCh)
return objCh
},
bucketName: "BUCKET-NAME",
path: "abc/def",
expectedStatusCode: http.StatusOK,
expectedBodyContains: "def",
},
} }
for _, tc := range cases { for _, tc := range cases {
@ -137,12 +164,12 @@ func TestHandleBucketView(t *testing.T) {
templates := os.DirFS(filepath.Join("..", "..", "..", "web", "template")) templates := os.DirFS(filepath.Join("..", "..", "..", "web", "template"))
r := mux.NewRouter() r := mux.NewRouter()
r.Handle("/buckets/{bucketName}", s3manager.HandleBucketView(s3, templates, true, true)).Methods(http.MethodGet) r.PathPrefix("/buckets/").Handler(s3manager.HandleBucketView(s3, templates, true, true)).Methods(http.MethodGet)
ts := httptest.NewServer(r) ts := httptest.NewServer(r)
defer ts.Close() defer ts.Close()
resp, err := http.Get(fmt.Sprintf("%s/buckets/%s", ts.URL, tc.bucketName)) resp, err := http.Get(fmt.Sprintf("%s/buckets/%s/%s", ts.URL, tc.bucketName, tc.path))
is.NoErr(err) is.NoErr(err)
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)

View file

@ -111,7 +111,7 @@ func main() {
r.Handle("/", http.RedirectHandler("/buckets", http.StatusPermanentRedirect)).Methods(http.MethodGet) r.Handle("/", http.RedirectHandler("/buckets", http.StatusPermanentRedirect)).Methods(http.MethodGet)
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.FS(statics)))).Methods(http.MethodGet) r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.FS(statics)))).Methods(http.MethodGet)
r.Handle("/buckets", s3manager.HandleBucketsView(s3, templates, allowDelete)).Methods(http.MethodGet) r.Handle("/buckets", s3manager.HandleBucketsView(s3, templates, allowDelete)).Methods(http.MethodGet)
r.Handle("/buckets/{bucketName}", s3manager.HandleBucketView(s3, templates, allowDelete, listRecursive)).Methods(http.MethodGet) r.PathPrefix("/buckets/").Handler(s3manager.HandleBucketView(s3, templates, allowDelete, listRecursive)).Methods(http.MethodGet)
r.Handle("/api/buckets", s3manager.HandleCreateBucket(s3)).Methods(http.MethodPost) r.Handle("/api/buckets", s3manager.HandleCreateBucket(s3)).Methods(http.MethodPost)
if allowDelete { if allowDelete {
r.Handle("/api/buckets/{bucketName}", s3manager.HandleDeleteBucket(s3)).Methods(http.MethodDelete) r.Handle("/api/buckets/{bucketName}", s3manager.HandleDeleteBucket(s3)).Methods(http.MethodDelete)

View file

@ -1,7 +1,13 @@
{{ define "content" }} {{ define "content" }}
<nav> <style>
.breadcrumb:before {
content: '/';
}
</style>
<nav class="nav-extended">
<div class="nav-wrapper container"> <div class="nav-wrapper container">
<span href="#" class="brand-logo"><i class="material-icons">folder_open</i>{{ .BucketName }}</span> <a href="/buckets/{{$.BucketName}}" class="brand-logo center"><i class="material-icons">folder_open</i>{{ .BucketName }}</a>
{{ if not .Objects }} {{ if not .Objects }}
<ul class="right"> <ul class="right">
<li> <li>
@ -12,18 +18,25 @@
</ul> </ul>
{{ end }} {{ end }}
</div> </div>
<div class="nav-wrapper container">
<a href="/buckets" class="breadcrumb"><i class="material-icons">arrow_back</i> buckets </a>
{{ $url := printf "/buckets/%s/" $.BucketName }}
<a href="{{ $url }}" class="breadcrumb">{{ $.BucketName }}</a>
{{ range $index, $path := .Paths }}
{{ $url = printf "%s%s/" $url $path }}
<a href="{{ $url }}" class="breadcrumb">{{ $path }}</a>
{{ end }}
</div>
</div>
</nav> </nav>
<div class="section"> <div class="section" style="margin: 10px;">
<a href="/buckets" style="padding-left:25px;vertical-align:middle;">
<i class="material-icons" style="vertical-align: middle;">arrow_back</i> Buckets
</a>
{{ if .Objects }} {{ if .Objects }}
<table class="striped"> <table class="striped">
<thead> <thead>
<tr> <tr>
<th style="width:75px;"></th>
<th>Key</th> <th>Key</th>
<th>Size</th> <th>Size</th>
<th>Owner</th> <th>Owner</th>
@ -35,20 +48,25 @@
<tbody> <tbody>
{{ range $index, $object := .Objects }} {{ range $index, $object := .Objects }}
<tr> <tr>
<td style="padding-left:25px;"><i class="material-icons">{{ $object.Icon }}</i></td> <td
<td>{{ $object.Info.Key }}</td> {{ if $object.IsFolder }}
<td>{{ $object.Info.Size }} bytes</td> onclick="location.href='/buckets/{{ $.BucketName }}/{{ $object.Key }}'"}
<td>{{ $object.Info.Owner }}</td> style="cursor:pointer;"
<td>{{ $object.Info.LastModified }}</td> {{ end }}>
<i class="material-icons">{{ $object.Icon }}</i> {{ $object.DisplayName }}
</td>
<td>{{ $object.Size }} bytes</td>
<td>{{ $object.Owner }}</td>
<td>{{ $object.LastModified }}</td>
<td> <td>
<button class="dropdown-trigger waves-effect waves-teal btn" data-target="actions-dropdown-{{ $index }}"> <button class="dropdown-trigger waves-effect waves-teal btn" data-target="actions-dropdown-{{ $index }}">
Actions <i class="material-icons right">arrow_drop_down</i> Actions <i class="material-icons right">arrow_drop_down</i>
</button> </button>
<!-- Dropdown Structure --> <!-- Dropdown Structure -->
<ul id="actions-dropdown-{{ $index }}" class="dropdown-content"> <ul id="actions-dropdown-{{ $index }}" class="dropdown-content">
<li><a target="_blank" href="/api/buckets/{{ $.BucketName }}/objects/{{ $object.Info.Key }}">Download</a></li> <li><a target="_blank" href="/api/buckets/{{ $.BucketName }}/objects/{{ $object.Key }}">Download</a></li>
{{- if $.AllowDelete }} {{- if $.AllowDelete }}
<li><a href="#" onclick="deleteObject({{ $.BucketName }}, {{ $object.Info.Key }})">Delete</a></li> <li><a href="#" onclick="deleteObject({{ $.BucketName }}, {{ $object.Key }})">Delete</a></li>
{{- end }} {{- end }}
</ul> </ul>
</td> </td>