From e8102320815bb952c107403e9260caca84236aa2 Mon Sep 17 00:00:00 2001 From: Matthias Baldi Date: Wed, 8 Jul 2026 20:15:00 +0200 Subject: [PATCH 1/4] feat: implement possibility to show item versions --- README.md | 1 + internal/app/s3manager/bucket_view.go | 249 +++++++++++++----- internal/app/s3manager/bucket_view_test.go | 121 ++++++++- internal/app/s3manager/get_object.go | 3 +- internal/app/s3manager/get_object_test.go | 30 ++- internal/app/s3manager/manager_handlers.go | 131 ++++----- .../app/s3manager/manager_handlers_test.go | 190 +++++++++++-- main.go | 7 +- web/template/bucket.html.tmpl | 58 +++- 9 files changed, 616 insertions(+), 174 deletions(-) diff --git a/README.md b/README.md index a1c8e13e..778e3b05 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ The application can be configured with the following environment variables: - `ALLOW_DELETE`: Enable buttons to delete objects (defaults to `true`) - `FORCE_DOWNLOAD`: Add response headers for object downloading instead of opening in a new tab (defaults to `true`) - `LIST_RECURSIVE`: List all objects in buckets recursively (defaults to `false`) +- `SHOW_VERSIONS`: Show all object versions in bucket view and enable version-specific downloads (defaults to `false`; bucket must have versioning enabled) - `TZ`: IANA timezone used when displaying object Last Modified times (defaults to UTC; for example `Europe/Berlin`) - `BUCKET_NAME`: Restrict the buckets view to a single named bucket (defaults to unset, showing all buckets) - `USE_IAM`: Use IAM role instead of key pair (defaults to `false`) diff --git a/internal/app/s3manager/bucket_view.go b/internal/app/s3manager/bucket_view.go index e27a68ea..0fb2ee9b 100644 --- a/internal/app/s3manager/bucket_view.go +++ b/internal/app/s3manager/bucket_view.go @@ -1,6 +1,7 @@ package s3manager import ( + "context" "fmt" "html/template" "io/fs" @@ -20,39 +21,160 @@ const defaultPerPage = 25 // objectWithIcon represents an S3 object with additional display properties type objectWithIcon struct { - Key string - Size int64 - SizeDisplay string - LastModified time.Time - Owner string - Icon string - IsFolder bool - DisplayName string + Key string + Size int64 + SizeDisplay string + LastModified time.Time + Owner string + Icon string + IsFolder bool + DisplayName string + VersionID string + IsLatest bool + IsDeleteMarker bool + VersionCount int + GroupIndex int + IsPrimaryVersion bool +} + +// annotateVersionGroups sets VersionCount, GroupIndex and IsPrimaryVersion on +// each object so the template can collapse older versions under their latest +// version by default. IsPrimaryVersion picks exactly one visible row per key: +// the one the provider marked IsLatest, or (since some S3-compatible providers +// leave IsLatest unset — notably folder entries synthesized from +// CommonPrefixes, which are never version-aware) the first entry seen for that +// key. Relying on the raw IsLatest flag alone would hide every row in a group +// where no entry has it set, making the bucket appear empty. +func annotateVersionGroups(objs []objectWithIcon) { + counts := make(map[string]int, len(objs)) + groupIndex := make(map[string]int, len(objs)) + primaryIndex := make(map[string]int, len(objs)) + nextIndex := 0 + + for i, obj := range objs { + counts[obj.Key]++ + if _, ok := groupIndex[obj.Key]; !ok { + groupIndex[obj.Key] = nextIndex + nextIndex++ + primaryIndex[obj.Key] = i + } else if obj.IsLatest { + primaryIndex[obj.Key] = i + } + } + + for i := range objs { + key := objs[i].Key + objs[i].VersionCount = counts[key] + objs[i].GroupIndex = groupIndex[key] + objs[i].IsPrimaryVersion = primaryIndex[key] == i + } +} + +// listObjectsOptions builds the minio.ListObjectsOptions used to list a bucket's objects. +func listObjectsOptions(listRecursive, showVersions bool, prefix string) minio.ListObjectsOptions { + return minio.ListObjectsOptions{ + Recursive: listRecursive, + Prefix: prefix, + WithVersions: showVersions, + } +} + +// listObjectsForBucketView lists a bucket's objects, converting each minio.ObjectInfo +// into an objectWithIcon. If showVersions is set but the versioned listing fails, or +// comes back empty (some S3-compatible providers don't support listing object +// versions and either reject the request outright or silently return nothing +// instead of erroring), it transparently falls back to a normal listing so the +// bucket can still be browsed. The returned bool reports whether version +// information is actually present in the result. +func listObjectsForBucketView(ctx context.Context, s3 S3, bucketName, path string, listRecursive, showVersions bool) ([]objectWithIcon, bool, error) { + if !showVersions { + objs, err := collectObjects(ctx, s3, bucketName, path, listObjectsOptions(listRecursive, false, path)) + return objs, false, err + } + + objs, err := collectObjects(ctx, s3, bucketName, path, listObjectsOptions(listRecursive, true, path)) + if err == nil && len(objs) > 0 { + return objs, true, nil + } + + fallbackObjs, fallbackErr := collectObjects(ctx, s3, bucketName, path, listObjectsOptions(listRecursive, false, path)) + if fallbackErr != nil { + return nil, false, fallbackErr + } + return fallbackObjs, false, nil +} + +// collectObjects drains an S3 ListObjects channel into a slice, returning the +// first error encountered (if any) instead of a partial, half-listed result. +func collectObjects(ctx context.Context, s3 S3, bucketName, path string, opts minio.ListObjectsOptions) ([]objectWithIcon, error) { + var objs []objectWithIcon + objectCh := s3.ListObjects(ctx, bucketName, opts) + for object := range objectCh { + if object.Err != nil { + return nil, object.Err + } + objs = append(objs, toObjectWithIcon(object, path)) + } + return objs, nil +} + +// friendlyListObjectsErrorMessage turns a raw S3 listing error into an +// actionable, user-facing message for the bucket view's error banner. +func friendlyListObjectsErrorMessage(err error, bucketName, instanceName string) string { + msg := err.Error() + + switch { + case strings.Contains(msg, "AccessDenied") || strings.Contains(msg, "InvalidAccessKeyId") || strings.Contains(msg, "SignatureDoesNotMatch"): + return fmt.Sprintf("Unable to access bucket '%s' on S3 instance '%s'. Please check the credentials and try switching to another instance.", bucketName, instanceName) + case strings.Contains(msg, ErrBucketDoesNotExist): + return fmt.Sprintf("Bucket '%s' does not exist on S3 instance '%s'. Please try switching to another instance or go back to the buckets list.", bucketName, instanceName) + default: + return fmt.Sprintf("Unable to list objects in bucket '%s' on S3 instance '%s': %s", bucketName, instanceName, msg) + } +} + +// toObjectWithIcon converts a minio.ObjectInfo into the template-facing objectWithIcon. +func toObjectWithIcon(object minio.ObjectInfo, path string) objectWithIcon { + return objectWithIcon{ + Key: object.Key, + Size: object.Size, + SizeDisplay: FormatFileSize(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), "/"), + VersionID: object.VersionID, + IsLatest: object.IsLatest, + IsDeleteMarker: object.IsDeleteMarker, + } } // HandleBucketView shows the details page of a bucket. -func HandleBucketView(s3 S3, templates fs.FS, allowDelete bool, listRecursive bool, rootURL string) http.HandlerFunc { +func HandleBucketView(s3 S3, templates fs.FS, allowDelete bool, listRecursive bool, rootURL string, showVersions bool) http.HandlerFunc { type pageData struct { - RootURL string - BucketName string - Objects []objectWithIcon - AllowDelete bool - Paths []string - CurrentPath string - Endpoint string - CurrentS3 *S3Instance - S3Instances []*S3Instance - HasError bool - ErrorMessage string - SortBy string - SortOrder string - Page int - PerPage int - TotalItems int - TotalPages int - HasPrevPage bool - HasNextPage bool - Search string + RootURL string + BucketName string + Objects []objectWithIcon + AllowDelete bool + Paths []string + CurrentPath string + Endpoint string + CurrentS3 *S3Instance + S3Instances []*S3Instance + HasError bool + ErrorMessage string + SortBy string + SortOrder string + Page int + PerPage int + TotalItems int + TotalPages int + HasPrevPage bool + HasNextPage bool + Search string + ShowVersions bool + VersionsUnavailable bool } return func(w http.ResponseWriter, r *http.Request) { @@ -95,29 +217,14 @@ func HandleBucketView(s3 S3, templates fs.FS, allowDelete bool, listRecursive bo // Get search parameter search := strings.TrimSpace(r.URL.Query().Get("search")) - var objs []objectWithIcon - opts := minio.ListObjectsOptions{ - Recursive: listRecursive, - Prefix: path, + objs, versionsShown, err := listObjectsForBucketView(r.Context(), s3, bucketName, path, listRecursive, showVersions) + if err != nil { + handleHTTPError(w, fmt.Errorf("error listing objects: %w", err)) + return } - objectCh := s3.ListObjects(r.Context(), bucketName, opts) - for object := range objectCh { - if object.Err != nil { - handleHTTPError(w, fmt.Errorf("error listing objects: %w", object.Err)) - return - } - obj := objectWithIcon{ - Key: object.Key, - Size: object.Size, - SizeDisplay: FormatFileSize(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) + if versionsShown { + annotateVersionGroups(objs) } // Filter objects based on search query @@ -163,26 +270,28 @@ func HandleBucketView(s3 S3, templates fs.FS, allowDelete bool, listRecursive bo } data := pageData{ - RootURL: rootURL, - BucketName: bucketName, - Objects: objs, - AllowDelete: allowDelete, - Paths: removeEmptyStrings(strings.Split(path, "/")), - CurrentPath: path, - Endpoint: s3.EndpointURL().String(), - CurrentS3: nil, - S3Instances: nil, - HasError: false, - ErrorMessage: "", - SortBy: sortBy, - SortOrder: sortOrder, - Page: page, - PerPage: perPage, - TotalItems: totalItems, - TotalPages: totalPages, - HasPrevPage: page > 1, - HasNextPage: page < totalPages, - Search: search, + RootURL: rootURL, + BucketName: bucketName, + Objects: objs, + AllowDelete: allowDelete, + Paths: removeEmptyStrings(strings.Split(path, "/")), + CurrentPath: path, + Endpoint: s3.EndpointURL().String(), + CurrentS3: nil, + S3Instances: nil, + HasError: false, + ErrorMessage: "", + SortBy: sortBy, + SortOrder: sortOrder, + Page: page, + PerPage: perPage, + TotalItems: totalItems, + TotalPages: totalPages, + HasPrevPage: page > 1, + HasNextPage: page < totalPages, + Search: search, + ShowVersions: versionsShown, + VersionsUnavailable: showVersions && !versionsShown, } funcMap := template.FuncMap{ diff --git a/internal/app/s3manager/bucket_view_test.go b/internal/app/s3manager/bucket_view_test.go index 5c9c84b4..0cea6225 100644 --- a/internal/app/s3manager/bucket_view_test.go +++ b/internal/app/s3manager/bucket_view_test.go @@ -28,8 +28,10 @@ func TestHandleBucketView(t *testing.T) { bucketName string rootUrl string path string + showVersions bool expectedStatusCode int expectedBodyContains string + unexpectedInBody []string }{ { it: "renders a bucket containing a file", @@ -177,6 +179,120 @@ func TestHandleBucketView(t *testing.T) { expectedStatusCode: http.StatusOK, expectedBodyContains: "def", }, + { + it: "does not show version columns when ShowVersions is disabled", + listObjectsFunc: func(context.Context, string, minio.ListObjectsOptions) <-chan minio.ObjectInfo { + objCh := make(chan minio.ObjectInfo) + go func() { + objCh <- minio.ObjectInfo{Key: "FILE-NAME", VersionID: "v1-abcdefghijk", IsLatest: true} + close(objCh) + }() + return objCh + }, + bucketName: "BUCKET-NAME", + showVersions: false, + expectedStatusCode: http.StatusOK, + expectedBodyContains: "FILE-NAME", + unexpectedInBody: []string{"Version ID", "v1-abcdef"}, + }, + { + it: "renders multiple versions when ShowVersions is enabled", + listObjectsFunc: func(_ context.Context, _ string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo { + objCh := make(chan minio.ObjectInfo) + go func() { + if opts.WithVersions { + objCh <- minio.ObjectInfo{Key: "FILE-NAME", VersionID: "v2-abcdefghijk", IsLatest: true} + objCh <- minio.ObjectInfo{Key: "FILE-NAME", VersionID: "v1-abcdefghijk", IsLatest: false} + } + close(objCh) + }() + return objCh + }, + bucketName: "BUCKET-NAME", + showVersions: true, + expectedStatusCode: http.StatusOK, + expectedBodyContains: "Latest", + }, + { + it: "falls back to a normal listing when the versioned listing fails", + listObjectsFunc: func(_ context.Context, _ string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo { + objCh := make(chan minio.ObjectInfo) + go func() { + defer close(objCh) + if opts.WithVersions { + objCh <- minio.ObjectInfo{Err: errS3} + return + } + objCh <- minio.ObjectInfo{Key: "FILE-NAME"} + }() + return objCh + }, + bucketName: "BUCKET-NAME", + showVersions: true, + expectedStatusCode: http.StatusOK, + expectedBodyContains: "FILE-NAME", + unexpectedInBody: []string{"Version ID"}, + }, + { + it: "falls back to a normal listing when the versioned listing succeeds but returns nothing", + listObjectsFunc: func(_ context.Context, _ string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo { + objCh := make(chan minio.ObjectInfo) + go func() { + defer close(objCh) + if opts.WithVersions { + return + } + objCh <- minio.ObjectInfo{Key: "FILE-NAME"} + }() + return objCh + }, + bucketName: "BUCKET-NAME", + showVersions: true, + expectedStatusCode: http.StatusOK, + expectedBodyContains: "FILE-NAME", + unexpectedInBody: []string{"Version ID"}, + }, + { + it: "collapses older versions by default with a toggle to expand them", + listObjectsFunc: func(_ context.Context, _ string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo { + objCh := make(chan minio.ObjectInfo) + go func() { + if opts.WithVersions { + objCh <- minio.ObjectInfo{Key: "FILE-NAME", VersionID: "v2-abcdefghijk", IsLatest: true} + objCh <- minio.ObjectInfo{Key: "FILE-NAME", VersionID: "v1-abcdefghijk", IsLatest: false} + } + close(objCh) + }() + return objCh + }, + bucketName: "BUCKET-NAME", + showVersions: true, + expectedStatusCode: http.StatusOK, + expectedBodyContains: `class="version-row" style="display: none;`, + }, + { + it: "does not hide folders or objects when the provider never sets IsLatest on versioned entries", + listObjectsFunc: func(_ context.Context, _ string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo { + objCh := make(chan minio.ObjectInfo) + go func() { + defer close(objCh) + if !opts.WithVersions { + return + } + // Folders synthesized from CommonPrefixes never carry + // version metadata, and some providers don't reliably + // set IsLatest on real objects either. + objCh <- minio.ObjectInfo{Key: "AFolder/"} + objCh <- minio.ObjectInfo{Key: "FILE-NAME", VersionID: "v1-abcdefghijk"} + }() + return objCh + }, + bucketName: "BUCKET-NAME", + showVersions: true, + expectedStatusCode: http.StatusOK, + expectedBodyContains: "AFolder", + unexpectedInBody: []string{`class="version-row" style="display: none;`}, + }, } for _, tc := range cases { @@ -194,7 +310,7 @@ func TestHandleBucketView(t *testing.T) { templates := os.DirFS(filepath.Join("..", "..", "..", "web", "template")) r := mux.NewRouter() - r.PathPrefix("/buckets/").Handler(s3manager.HandleBucketView(s3, templates, true, true, tc.rootUrl)).Methods(http.MethodGet) + r.PathPrefix("/buckets/").Handler(s3manager.HandleBucketView(s3, templates, true, true, tc.rootUrl, tc.showVersions)).Methods(http.MethodGet) ts := httptest.NewServer(r) defer ts.Close() @@ -210,6 +326,9 @@ func TestHandleBucketView(t *testing.T) { is.Equal(tc.expectedStatusCode, resp.StatusCode) // status code is.True(strings.Contains(string(body), tc.expectedBodyContains)) // body + for _, unexpected := range tc.unexpectedInBody { + is.True(!strings.Contains(string(body), unexpected)) + } // fmt.Println(string(body)) if tc.expectedStatusCode == http.StatusOK { diff --git a/internal/app/s3manager/get_object.go b/internal/app/s3manager/get_object.go index c0f1630f..2922b76c 100644 --- a/internal/app/s3manager/get_object.go +++ b/internal/app/s3manager/get_object.go @@ -14,8 +14,9 @@ func HandleGetObject(s3 S3, forceDownload bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { bucketName := mux.Vars(r)["bucketName"] objectName := mux.Vars(r)["objectName"] + versionID := r.URL.Query().Get("versionId") - object, err := s3.GetObject(r.Context(), bucketName, objectName, minio.GetObjectOptions{}) + object, err := s3.GetObject(r.Context(), bucketName, objectName, minio.GetObjectOptions{VersionID: versionID}) if err != nil { handleHTTPError(w, fmt.Errorf("error getting object: %w", err)) return diff --git a/internal/app/s3manager/get_object_test.go b/internal/app/s3manager/get_object_test.go index 2fc22807..b9d38a61 100644 --- a/internal/app/s3manager/get_object_test.go +++ b/internal/app/s3manager/get_object_test.go @@ -24,6 +24,7 @@ func TestHandleGetObject(t *testing.T) { getObjectFunc func(context.Context, string, string, minio.GetObjectOptions) (*minio.Object, error) bucketName string objectName string + queryString string expectedStatusCode int expectedBodyContains string }{ @@ -37,6 +38,33 @@ func TestHandleGetObject(t *testing.T) { expectedStatusCode: http.StatusInternalServerError, expectedBodyContains: "mocked s3 error", }, + { + it: "leaves VersionID empty when no versionId query param is given", + getObjectFunc: func(_ context.Context, _, _ string, opts minio.GetObjectOptions) (*minio.Object, error) { + if opts.VersionID != "" { + return nil, fmt.Errorf("expected empty VersionID, got %q", opts.VersionID) + } + return nil, errS3 + }, + bucketName: "BUCKET-NAME", + objectName: "OBJECT-NAME", + expectedStatusCode: http.StatusInternalServerError, + expectedBodyContains: "mocked s3 error", + }, + { + it: "passes the versionId query param through to GetObjectOptions", + getObjectFunc: func(_ context.Context, _, _ string, opts minio.GetObjectOptions) (*minio.Object, error) { + if opts.VersionID != "VERSION-123" { + return nil, fmt.Errorf("expected VersionID %q, got %q", "VERSION-123", opts.VersionID) + } + return nil, errS3 + }, + bucketName: "BUCKET-NAME", + objectName: "OBJECT-NAME", + queryString: "?versionId=VERSION-123", + expectedStatusCode: http.StatusInternalServerError, + expectedBodyContains: "mocked s3 error", + }, } for _, tc := range cases { @@ -54,7 +82,7 @@ func TestHandleGetObject(t *testing.T) { ts := httptest.NewServer(r) defer ts.Close() - resp, err := http.Get(fmt.Sprintf("%s/buckets/%s/objects/%s", ts.URL, tc.bucketName, tc.objectName)) + resp, err := http.Get(fmt.Sprintf("%s/buckets/%s/objects/%s%s", ts.URL, tc.bucketName, tc.objectName, tc.queryString)) is.NoErr(err) defer func() { err = resp.Body.Close() diff --git a/internal/app/s3manager/manager_handlers.go b/internal/app/s3manager/manager_handlers.go index 9337328e..e29f34f3 100644 --- a/internal/app/s3manager/manager_handlers.go +++ b/internal/app/s3manager/manager_handlers.go @@ -10,7 +10,6 @@ import ( "strings" "github.com/gorilla/mux" - "github.com/minio/minio-go/v7" ) // withInstance extracts the instance from the request, looks it up in the manager, @@ -98,7 +97,7 @@ func HandleBucketsViewWithManager(manager *MultiS3Manager, templates fs.FS, allo } // HandleBucketViewWithManager shows the details page of a bucket using MultiS3Manager. -func HandleBucketViewWithManager(manager *MultiS3Manager, templates fs.FS, allowDelete bool, listRecursive bool, rootURL string) http.HandlerFunc { +func HandleBucketViewWithManager(manager *MultiS3Manager, templates fs.FS, allowDelete bool, listRecursive bool, rootURL string, showVersions bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) instanceName := vars["instance"] @@ -113,7 +112,7 @@ func HandleBucketViewWithManager(manager *MultiS3Manager, templates fs.FS, allow instances := manager.GetAllInstances() // Create a modified handler that includes S3 instance data - handler := createBucketViewWithS3Data(s3, templates, allowDelete, listRecursive, rootURL, current, instances) + handler := createBucketViewWithS3Data(s3, templates, allowDelete, listRecursive, rootURL, current, instances, showVersions) handler(w, r) } } @@ -154,28 +153,30 @@ func HandleCheckPublicAccessWithManager(manager *MultiS3Manager) http.HandlerFun } // createBucketViewWithS3Data creates a bucket view handler that includes S3 instance data -func createBucketViewWithS3Data(s3 S3, templates fs.FS, allowDelete bool, listRecursive bool, rootURL string, current *S3Instance, instances []*S3Instance) http.HandlerFunc { +func createBucketViewWithS3Data(s3 S3, templates fs.FS, allowDelete bool, listRecursive bool, rootURL string, current *S3Instance, instances []*S3Instance, showVersions bool) http.HandlerFunc { type pageData struct { - RootURL string - BucketName string - Objects []objectWithIcon - AllowDelete bool - Paths []string - CurrentPath string - Endpoint string - CurrentS3 *S3Instance - S3Instances []*S3Instance - HasError bool - ErrorMessage string - SortBy string - SortOrder string - Page int - PerPage int - TotalItems int - TotalPages int - HasPrevPage bool - HasNextPage bool - Search string + RootURL string + BucketName string + Objects []objectWithIcon + AllowDelete bool + Paths []string + CurrentPath string + Endpoint string + CurrentS3 *S3Instance + S3Instances []*S3Instance + HasError bool + ErrorMessage string + SortBy string + SortOrder string + Page int + PerPage int + TotalItems int + TotalPages int + HasPrevPage bool + HasNextPage bool + Search string + ShowVersions bool + VersionsUnavailable bool } return func(w http.ResponseWriter, r *http.Request) { @@ -227,42 +228,16 @@ func createBucketViewWithS3Data(s3 S3, templates fs.FS, allowDelete bool, listRe // Get search parameter search := strings.TrimSpace(r.URL.Query().Get("search")) - var objs []objectWithIcon hasError := false errorMessage := "" - opts := minio.ListObjectsOptions{ - Recursive: listRecursive, - Prefix: path, - } - objectCh := s3.ListObjects(r.Context(), bucketName, opts) - for object := range objectCh { - if object.Err != nil { - // Instead of returning HTTP error, show user-friendly message - hasError = true - if strings.Contains(object.Err.Error(), "AccessDenied") || strings.Contains(object.Err.Error(), "InvalidAccessKeyId") || strings.Contains(object.Err.Error(), "SignatureDoesNotMatch") { - errorMessage = fmt.Sprintf("Unable to access bucket '%s' on S3 instance '%s'. Please check the credentials and try switching to another instance.", bucketName, current.Name) - } else if strings.Contains(object.Err.Error(), ErrBucketDoesNotExist) { - errorMessage = fmt.Sprintf("Bucket '%s' does not exist on S3 instance '%s'. Please try switching to another instance or go back to the buckets list.", bucketName, current.Name) - } else { - errorMessage = fmt.Sprintf("Unable to list objects in bucket '%s' on S3 instance '%s'. Please try switching to another instance.", bucketName, current.Name) - } - break - } - - sizeDisplay := FormatFileSize(object.Size) - - obj := objectWithIcon{ - Key: object.Key, - Size: object.Size, - SizeDisplay: sizeDisplay, - 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, versionsShown, listErr := listObjectsForBucketView(r.Context(), s3, bucketName, path, listRecursive, showVersions) + if listErr != nil { + // Instead of returning HTTP error, show user-friendly message + hasError = true + errorMessage = friendlyListObjectsErrorMessage(listErr, bucketName, current.Name) + } else if versionsShown { + annotateVersionGroups(objs) } // Filter objects based on search query @@ -323,26 +298,28 @@ func createBucketViewWithS3Data(s3 S3, templates fs.FS, allowDelete bool, listRe } data := pageData{ - RootURL: rootURL, - BucketName: bucketName, - Objects: objs, - AllowDelete: allowDelete, - Paths: removeEmptyStrings(strings.Split(path, "/")), - CurrentPath: path, - Endpoint: s3.EndpointURL().String(), - CurrentS3: current, - S3Instances: instances, - HasError: hasError, - ErrorMessage: errorMessage, - SortBy: sortBy, - SortOrder: sortOrder, - Page: page, - PerPage: perPage, - TotalItems: totalItems, - TotalPages: totalPages, - HasPrevPage: page > 1, - HasNextPage: page < totalPages, - Search: search, + RootURL: rootURL, + BucketName: bucketName, + Objects: objs, + AllowDelete: allowDelete, + Paths: removeEmptyStrings(strings.Split(path, "/")), + CurrentPath: path, + Endpoint: s3.EndpointURL().String(), + CurrentS3: current, + S3Instances: instances, + HasError: hasError, + ErrorMessage: errorMessage, + SortBy: sortBy, + SortOrder: sortOrder, + Page: page, + PerPage: perPage, + TotalItems: totalItems, + TotalPages: totalPages, + HasPrevPage: page > 1, + HasNextPage: page < totalPages, + Search: search, + ShowVersions: versionsShown, + VersionsUnavailable: showVersions && !versionsShown && !hasError, } funcMap := template.FuncMap{ diff --git a/internal/app/s3manager/manager_handlers_test.go b/internal/app/s3manager/manager_handlers_test.go index c2c4e6b5..8af2c27b 100644 --- a/internal/app/s3manager/manager_handlers_test.go +++ b/internal/app/s3manager/manager_handlers_test.go @@ -765,29 +765,181 @@ func TestHandleCreateObjectWithManager(t *testing.T) { func TestHandleBucketViewWithManager(t *testing.T) { t.Parallel() - is := is.New(t) - templates := os.DirFS(filepath.Join("..", "..", "..", "web", "template")) + versionedListObjects := func(_ context.Context, _ string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo { + ch := make(chan minio.ObjectInfo) + go func() { + defer close(ch) + if !opts.WithVersions { + return + } + ch <- minio.ObjectInfo{Key: "FILE-NAME", VersionID: "v2-abcdefghijk", IsLatest: true} + ch <- minio.ObjectInfo{Key: "FILE-NAME", VersionID: "v1-abcdefghijk", IsLatest: false} + }() + return ch + } - manager := newTestMultiS3Manager([]*S3Instance{ - {ID: "1", Name: "primary", Client: &stubS3{}}, - }) + cases := []struct { + it string + path string + client S3 + showVersions bool + expectedStatusCode int + expectedBodyContains []string + unexpectedInBody []string + }{ + { + it: "returns not found for an unknown instance", + path: "/unknown/buckets/test-bucket/", + client: &stubS3{}, + expectedStatusCode: http.StatusNotFound, + expectedBodyContains: []string{ + "Instance not found", + }, + }, + { + it: "does not show version columns when ShowVersions is disabled", + path: "/primary/buckets/test-bucket/", + client: &stubS3{ + listObjects: versionedListObjects, + endpointURL: func() *url.URL { u, _ := url.Parse("http://localhost:9000"); return u }, + }, + showVersions: false, + expectedStatusCode: http.StatusOK, + unexpectedInBody: []string{ + "Version ID", + "v1-abcdef", + "v2-abcdef", + }, + }, + { + it: "renders multiple versions when ShowVersions is enabled", + path: "/primary/buckets/test-bucket/", + client: &stubS3{ + listObjects: versionedListObjects, + endpointURL: func() *url.URL { u, _ := url.Parse("http://localhost:9000"); return u }, + }, + showVersions: true, + expectedStatusCode: http.StatusOK, + expectedBodyContains: []string{ + "Version ID", + "v1-abcdef", + "v2-abcdef", + "Latest", + }, + }, + { + it: "falls back to a normal listing and shows a notice when the provider rejects listing versions", + path: "/primary/buckets/test-bucket/", + client: &stubS3{ + listObjects: func(_ context.Context, _ string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo { + ch := make(chan minio.ObjectInfo) + go func() { + defer close(ch) + if opts.WithVersions { + ch <- minio.ObjectInfo{Err: errManagerTest} + return + } + ch <- minio.ObjectInfo{Key: "FILE-NAME"} + }() + return ch + }, + endpointURL: func() *url.URL { u, _ := url.Parse("http://localhost:9000"); return u }, + }, + showVersions: true, + expectedStatusCode: http.StatusOK, + expectedBodyContains: []string{ + "FILE-NAME", + "Object versions unavailable", + }, + unexpectedInBody: []string{ + "Version ID", + }, + }, + { + it: "surfaces the underlying S3 error when listing fails for a reason other than versioning", + path: "/primary/buckets/test-bucket/", + client: &stubS3{ + listObjects: func(_ context.Context, _ string, _ minio.ListObjectsOptions) <-chan minio.ObjectInfo { + ch := make(chan minio.ObjectInfo) + go func() { + defer close(ch) + ch <- minio.ObjectInfo{Err: errManagerTest} + }() + return ch + }, + endpointURL: func() *url.URL { u, _ := url.Parse("http://localhost:9000"); return u }, + }, + showVersions: false, + expectedStatusCode: http.StatusOK, + expectedBodyContains: []string{ + errManagerTest.Error(), + }, + }, + { + it: "falls back to a normal listing when the versioned listing succeeds but returns nothing", + path: "/primary/buckets/test-bucket/", + client: &stubS3{ + listObjects: func(_ context.Context, _ string, opts minio.ListObjectsOptions) <-chan minio.ObjectInfo { + ch := make(chan minio.ObjectInfo) + go func() { + defer close(ch) + if opts.WithVersions { + // Some S3-compatible providers silently return an + // empty result instead of erroring when versioned + // listing isn't supported. + return + } + ch <- minio.ObjectInfo{Key: "FILE-NAME"} + }() + return ch + }, + endpointURL: func() *url.URL { u, _ := url.Parse("http://localhost:9000"); return u }, + }, + showVersions: true, + expectedStatusCode: http.StatusOK, + expectedBodyContains: []string{ + "FILE-NAME", + }, + unexpectedInBody: []string{ + "Version ID", + }, + }, + } - r := mux.NewRouter() - r.PathPrefix("/{instance}/buckets/").Handler(HandleBucketViewWithManager(manager, templates, true, true, "")).Methods(http.MethodGet) + for _, tc := range cases { + t.Run(tc.it, func(t *testing.T) { + t.Parallel() + is := is.New(t) - ts := httptest.NewServer(r) - defer ts.Close() + templates := os.DirFS(filepath.Join("..", "..", "..", "web", "template")) - resp, err := http.Get(ts.URL + "/unknown/buckets/test-bucket/") - is.NoErr(err) - defer func() { - err = resp.Body.Close() - is.NoErr(err) - }() - body, err := io.ReadAll(resp.Body) - is.NoErr(err) + manager := newTestMultiS3Manager([]*S3Instance{ + {ID: "1", Name: "primary", Client: tc.client}, + }) - is.Equal(http.StatusNotFound, resp.StatusCode) - is.True(strings.Contains(string(body), "Instance not found")) + r := mux.NewRouter() + r.PathPrefix("/{instance}/buckets/").Handler(HandleBucketViewWithManager(manager, templates, true, true, "", tc.showVersions)).Methods(http.MethodGet) + + ts := httptest.NewServer(r) + defer ts.Close() + + resp, err := http.Get(ts.URL + tc.path) + is.NoErr(err) + defer func() { + err = resp.Body.Close() + is.NoErr(err) + }() + body, err := io.ReadAll(resp.Body) + is.NoErr(err) + + is.Equal(tc.expectedStatusCode, resp.StatusCode) + for _, expected := range tc.expectedBodyContains { + is.True(strings.Contains(string(body), expected)) + } + for _, unexpected := range tc.unexpectedInBody { + is.True(!strings.Contains(string(body), unexpected)) + } + }) + } } diff --git a/main.go b/main.go index a0c5c58b..e5a0309b 100644 --- a/main.go +++ b/main.go @@ -28,6 +28,7 @@ type configuration struct { AllowDelete bool ForceDownload bool ListRecursive bool + ShowVersions bool Port string Timeout int32 SseType string @@ -107,6 +108,9 @@ func parseConfiguration() configuration { listRecursive := viper.GetBool("LIST_RECURSIVE") + viper.SetDefault("SHOW_VERSIONS", false) + showVersions := viper.GetBool("SHOW_VERSIONS") + viper.SetDefault("PORT", "8080") port := viper.GetString("PORT") @@ -127,6 +131,7 @@ func parseConfiguration() configuration { AllowDelete: allowDelete, ForceDownload: forceDownload, ListRecursive: listRecursive, + ShowVersions: showVersions, Port: port, Timeout: timeout, SseType: sseType, @@ -184,7 +189,7 @@ func main() { // S3 management endpoints (with instance in URL) r.Handle("/{instance}/buckets", s3manager.HandleBucketsViewWithManager(s3Manager, templates, configuration.AllowDelete, rootURL, configuration.BucketName)).Methods(http.MethodGet) - r.PathPrefix("/{instance}/buckets/").Handler(s3manager.HandleBucketViewWithManager(s3Manager, templates, configuration.AllowDelete, configuration.ListRecursive, rootURL)).Methods(http.MethodGet) + r.PathPrefix("/{instance}/buckets/").Handler(s3manager.HandleBucketViewWithManager(s3Manager, templates, configuration.AllowDelete, configuration.ListRecursive, rootURL, configuration.ShowVersions)).Methods(http.MethodGet) r.Handle("/{instance}/api/buckets", s3manager.HandleCreateBucketWithManager(s3Manager)).Methods(http.MethodPost) if configuration.AllowDelete { r.Handle("/{instance}/api/buckets/{bucketName}", s3manager.HandleDeleteBucketWithManager(s3Manager)).Methods(http.MethodDelete) diff --git a/web/template/bucket.html.tmpl b/web/template/bucket.html.tmpl index 37fb3925..1d8f582d 100644 --- a/web/template/bucket.html.tmpl +++ b/web/template/bucket.html.tmpl @@ -102,7 +102,20 @@ {{ else }} - + + {{ if .VersionsUnavailable }} +
+
+
+
+ Object versions unavailable +

This bucket does not support listing object versions (it may not have versioning enabled). Showing the latest version of each object instead.

+
+
+
+
+ {{ end }} +
@@ -192,13 +205,22 @@ {{ end }} {{ end }} + {{ if $.ShowVersions }} + Version ID + Latest + {{ end }} {{ range $index, $object := .Objects }} - + {{ $isCollapsedVersion := and $.ShowVersions (not $object.IsPrimaryVersion) }} + {{ if not $object.IsFolder }}
+ +