diff --git a/internal/app/s3manager/bucket_view.go b/internal/app/s3manager/bucket_view.go index e373e482..05134e6a 100644 --- a/internal/app/s3manager/bucket_view.go +++ b/internal/app/s3manager/bucket_view.go @@ -26,12 +26,16 @@ func HandleBucketView(s3 S3, templates fs.FS, allowDelete bool, listRecursive bo } type pageData struct { - RootURL string - BucketName string - Objects []objectWithIcon - AllowDelete bool - Paths []string - CurrentPath string + RootURL string + BucketName string + Objects []objectWithIcon + AllowDelete bool + Paths []string + CurrentPath string + Endpoint string + CurrentS3 *S3Instance + HasError bool + ErrorMessage string } return func(w http.ResponseWriter, r *http.Request) { @@ -70,6 +74,7 @@ func HandleBucketView(s3 S3, templates fs.FS, allowDelete bool, listRecursive bo AllowDelete: allowDelete, Paths: removeEmptyStrings(strings.Split(path, "/")), CurrentPath: path, + Endpoint: s3.EndpointURL().String(), } t, err := template.ParseFS(templates, "layout.html.tmpl", "bucket.html.tmpl") diff --git a/internal/app/s3manager/bucket_view_test.go b/internal/app/s3manager/bucket_view_test.go index 3dbaee87..39da869e 100644 --- a/internal/app/s3manager/bucket_view_test.go +++ b/internal/app/s3manager/bucket_view_test.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "strings" @@ -185,6 +186,10 @@ func TestHandleBucketView(t *testing.T) { s3 := &mocks.S3Mock{ ListObjectsFunc: tc.listObjectsFunc, + EndpointURLFunc: func() *url.URL { + u, _ := url.Parse("http://localhost:9000") + return u + }, } templates := os.DirFS(filepath.Join("..", "..", "..", "web", "template")) diff --git a/internal/app/s3manager/buckets_view.go b/internal/app/s3manager/buckets_view.go index 748228ec..83c89056 100644 --- a/internal/app/s3manager/buckets_view.go +++ b/internal/app/s3manager/buckets_view.go @@ -12,9 +12,12 @@ import ( // HandleBucketsView renders all buckets on an HTML page. func HandleBucketsView(s3 S3, templates fs.FS, allowDelete bool, rootURL string) http.HandlerFunc { type pageData struct { - RootURL string - Buckets []minio.BucketInfo - AllowDelete bool + RootURL string + Buckets []minio.BucketInfo + AllowDelete bool + CurrentS3 *S3Instance + HasError bool + ErrorMessage string } return func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/app/s3manager/manager_handlers.go b/internal/app/s3manager/manager_handlers.go index f5aa0787..7a3d3027 100644 --- a/internal/app/s3manager/manager_handlers.go +++ b/internal/app/s3manager/manager_handlers.go @@ -137,6 +137,16 @@ func HandleDeleteObjectWithManager(manager *MultiS3Manager) http.HandlerFunc { } } +// HandleCheckPublicAccessWithManager checks if an object is publicly accessible using MultiS3Manager. +func HandleCheckPublicAccessWithManager(manager *MultiS3Manager) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s3 := manager.GetCurrentClient() + // Delegate to the original handler with the current S3 client + handler := HandleCheckPublicAccess(s3) + handler(w, r) + } +} + // 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 { type objectWithIcon struct { @@ -161,6 +171,7 @@ func createBucketViewWithS3Data(s3 S3, templates fs.FS, allowDelete bool, listRe S3Instances []*S3Instance HasError bool ErrorMessage string + Endpoint string } return func(w http.ResponseWriter, r *http.Request) { @@ -218,6 +229,7 @@ func createBucketViewWithS3Data(s3 S3, templates fs.FS, allowDelete bool, listRe S3Instances: instances, HasError: hasError, ErrorMessage: errorMessage, + Endpoint: s3.EndpointURL().String(), } t, err := template.ParseFS(templates, "layout.html.tmpl", "bucket.html.tmpl") diff --git a/internal/app/s3manager/mocks/s3.go b/internal/app/s3manager/mocks/s3.go index 2c1cb382..e2216758 100644 --- a/internal/app/s3manager/mocks/s3.go +++ b/internal/app/s3manager/mocks/s3.go @@ -23,6 +23,9 @@ var _ s3manager.S3 = &S3Mock{} // // // make and configure a mocked s3manager.S3 // mockedS3 := &S3Mock{ +// EndpointURLFunc: func() *url.URL { +// panic("mock out the EndpointURL method") +// }, // GetObjectFunc: func(ctx context.Context, bucketName string, objectName string, opts minio.GetObjectOptions) (*minio.Object, error) { // panic("mock out the GetObject method") // }, @@ -54,6 +57,9 @@ var _ s3manager.S3 = &S3Mock{} // // } type S3Mock struct { + // EndpointURLFunc mocks the EndpointURL method. + EndpointURLFunc func() *url.URL + // GetObjectFunc mocks the GetObject method. GetObjectFunc func(ctx context.Context, bucketName string, objectName string, opts minio.GetObjectOptions) (*minio.Object, error) @@ -80,6 +86,9 @@ type S3Mock struct { // calls tracks calls to the methods. calls struct { + // EndpointURL holds details about calls to the EndpointURL method. + EndpointURL []struct { + } // GetObject holds details about calls to the GetObject method. GetObject []struct { // Ctx is the ctx argument value. @@ -161,6 +170,7 @@ type S3Mock struct { Opts minio.RemoveObjectOptions } } + lockEndpointURL sync.RWMutex lockGetObject sync.RWMutex lockListBuckets sync.RWMutex lockListObjects sync.RWMutex @@ -171,6 +181,33 @@ type S3Mock struct { lockRemoveObject sync.RWMutex } +// EndpointURL calls EndpointURLFunc. +func (mock *S3Mock) EndpointURL() *url.URL { + if mock.EndpointURLFunc == nil { + panic("S3Mock.EndpointURLFunc: method is nil but S3.EndpointURL was just called") + } + callInfo := struct { + }{} + mock.lockEndpointURL.Lock() + mock.calls.EndpointURL = append(mock.calls.EndpointURL, callInfo) + mock.lockEndpointURL.Unlock() + return mock.EndpointURLFunc() +} + +// EndpointURLCalls gets all the calls that were made to EndpointURL. +// Check the length with: +// +// len(mockedS3.EndpointURLCalls()) +func (mock *S3Mock) EndpointURLCalls() []struct { +} { + var calls []struct { + } + mock.lockEndpointURL.RLock() + calls = mock.calls.EndpointURL + mock.lockEndpointURL.RUnlock() + return calls +} + // GetObject calls GetObjectFunc. func (mock *S3Mock) GetObject(ctx context.Context, bucketName string, objectName string, opts minio.GetObjectOptions) (*minio.Object, error) { if mock.GetObjectFunc == nil { diff --git a/internal/app/s3manager/public_access.go b/internal/app/s3manager/public_access.go new file mode 100644 index 00000000..97df5344 --- /dev/null +++ b/internal/app/s3manager/public_access.go @@ -0,0 +1,56 @@ +package s3manager + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/gorilla/mux" +) + +// HandleCheckPublicAccess checks if an object is publicly accessible. +func HandleCheckPublicAccess(s3 S3) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + bucketName := mux.Vars(r)["bucketName"] + objectName := mux.Vars(r)["objectName"] + + endpoint := s3.EndpointURL().String() + if !strings.HasSuffix(endpoint, "/") { + endpoint += "/" + } + + // Construct the public URL + // Note: This assumes path-style access (http://endpoint/bucket/object) + // which is typical for MinIO and generic S3. + url := fmt.Sprintf("%s%s/%s", endpoint, bucketName, objectName) + + // Perform a HEAD request to check accessibility without downloading content + resp, err := http.Head(url) + isAccessible := false + statusCode := 0 + + if err != nil { + // If we can't reach it, it's definitely not accessible or there's a network issue + // We treat this as not accessible for the user's purpose + isAccessible = false + } else { + defer resp.Body.Close() + statusCode = resp.StatusCode + // 200 OK means accessible. + // We might also consider 304 Not Modified as accessible if that ever happens on a fresh HEAD. + isAccessible = resp.StatusCode == http.StatusOK + } + + response := map[string]interface{}{ + "accessible": isAccessible, + "statusCode": statusCode, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + handleHTTPError(w, fmt.Errorf("error encoding JSON: %w", err)) + return + } + } +} diff --git a/internal/app/s3manager/public_access_test.go b/internal/app/s3manager/public_access_test.go new file mode 100644 index 00000000..b8faba51 --- /dev/null +++ b/internal/app/s3manager/public_access_test.go @@ -0,0 +1,96 @@ +package s3manager_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/cloudlena/s3manager/internal/app/s3manager" + "github.com/cloudlena/s3manager/internal/app/s3manager/mocks" + "github.com/gorilla/mux" + "github.com/matryer/is" +) + +func TestHandleCheckPublicAccess(t *testing.T) { + t.Parallel() + + cases := []struct { + it string + s3ResponseStatus int + expectAccessible bool + expectStatusCode int + networkError bool + }{ + { + it: "reports accessible when S3 returns 200 OK", + s3ResponseStatus: http.StatusOK, + expectAccessible: true, + expectStatusCode: http.StatusOK, + }, + { + it: "reports not accessible when S3 returns 403 Forbidden", + s3ResponseStatus: http.StatusForbidden, + expectAccessible: false, + expectStatusCode: http.StatusForbidden, + }, + { + it: "reports not accessible when S3 returns 404 Not Found", + s3ResponseStatus: http.StatusNotFound, + expectAccessible: false, + expectStatusCode: http.StatusNotFound, + }, + { + it: "reports not accessible on network error", + networkError: true, + expectAccessible: false, + expectStatusCode: 0, + }, + } + + for _, tc := range cases { + t.Run(tc.it, func(t *testing.T) { + is := is.New(t) + + // Start a mock S3 server to respond to the HEAD request + var s3ServerURL string + if !tc.networkError { + s3Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + is.Equal(http.MethodHead, r.Method) + w.WriteHeader(tc.s3ResponseStatus) + })) + defer s3Server.Close() + s3ServerURL = s3Server.URL + } else { + // Use an invalid port to simulate a network error + s3ServerURL = "http://localhost:0" + } + + s3 := &mocks.S3Mock{ + EndpointURLFunc: func() *url.URL { + u, _ := url.Parse(s3ServerURL) + return u + }, + } + + handler := s3manager.HandleCheckPublicAccess(s3) + r := mux.NewRouter() + r.Handle("/api/buckets/{bucketName}/objects/{objectName:.*}/public-access", handler) + + req := httptest.NewRequest(http.MethodGet, "/api/buckets/my-bucket/objects/my-file.txt/public-access", nil) + rr := httptest.NewRecorder() + + r.ServeHTTP(rr, req) + + is.Equal(http.StatusOK, rr.Code) + + var response map[string]interface{} + err := json.Unmarshal(rr.Body.Bytes(), &response) + is.NoErr(err) + + is.Equal(tc.expectAccessible, response["accessible"]) + is.Equal(float64(tc.expectStatusCode), response["statusCode"]) + }) + } +} diff --git a/internal/app/s3manager/s3.go b/internal/app/s3manager/s3.go index 75c22f33..f77248d1 100644 --- a/internal/app/s3manager/s3.go +++ b/internal/app/s3manager/s3.go @@ -21,6 +21,7 @@ type S3 interface { PutObject(ctx context.Context, bucketName, objectName string, reader io.Reader, objectSize int64, opts minio.PutObjectOptions) (minio.UploadInfo, error) RemoveBucket(ctx context.Context, bucketName string) error RemoveObject(ctx context.Context, bucketName, objectName string, opts minio.RemoveObjectOptions) error + EndpointURL() *url.URL } // SSEType describes a type of server side encryption. diff --git a/main.go b/main.go index 27345134..7b8a4a65 100644 --- a/main.go +++ b/main.go @@ -206,6 +206,7 @@ func main() { } r.Handle("/api/buckets/{bucketName}/objects", s3manager.HandleCreateObjectWithManager(s3Manager, sseType)).Methods(http.MethodPost) r.Handle("/api/buckets/{bucketName}/objects/{objectName:.*}/url", s3manager.HandleGenerateURLWithManager(s3Manager)).Methods(http.MethodGet) + r.Handle("/api/buckets/{bucketName}/objects/{objectName:.*}/public-access", s3manager.HandleCheckPublicAccessWithManager(s3Manager)).Methods(http.MethodGet) r.Handle("/api/buckets/{bucketName}/objects/{objectName:.*}", s3manager.HandleGetObjectWithManager(s3Manager, configuration.ForceDownload)).Methods(http.MethodGet) if configuration.AllowDelete { r.Handle("/api/buckets/{bucketName}/objects/{objectName:.*}", s3manager.HandleDeleteObjectWithManager(s3Manager)).Methods(http.MethodDelete) diff --git a/web/template/bucket.html.tmpl b/web/template/bucket.html.tmpl index c54d1ee1..d9d204a9 100644 --- a/web/template/bucket.html.tmpl +++ b/web/template/bucket.html.tmpl @@ -103,9 +103,10 @@