Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions internal/app/s3manager/bucket_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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")
Expand Down
5 changes: 5 additions & 0 deletions internal/app/s3manager/bucket_view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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"))
Expand Down
9 changes: 6 additions & 3 deletions internal/app/s3manager/buckets_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
12 changes: 12 additions & 0 deletions internal/app/s3manager/manager_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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")
Expand Down
37 changes: 37 additions & 0 deletions internal/app/s3manager/mocks/s3.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

56 changes: 56 additions & 0 deletions internal/app/s3manager/public_access.go
Original file line number Diff line number Diff line change
@@ -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()

Check failure on line 38 in internal/app/s3manager/public_access.go

View workflow job for this annotation

GitHub Actions / verify

Error return value of `resp.Body.Close` is not checked (errcheck)
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
}
}
}
96 changes: 96 additions & 0 deletions internal/app/s3manager/public_access_test.go
Original file line number Diff line number Diff line change
@@ -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"])
})
}
}
1 change: 1 addition & 0 deletions internal/app/s3manager/s3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading