Skip to content

Commit 1b93fd3

Browse files
committed
feat: add public share link functionality and improve error handling
1 parent 354a506 commit 1b93fd3

File tree

10 files changed

+353
-11
lines changed

10 files changed

+353
-11
lines changed

internal/app/s3manager/bucket_view.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,16 @@ func HandleBucketView(s3 S3, templates fs.FS, allowDelete bool, listRecursive bo
2626
}
2727

2828
type pageData struct {
29-
RootURL string
30-
BucketName string
31-
Objects []objectWithIcon
32-
AllowDelete bool
33-
Paths []string
34-
CurrentPath string
29+
RootURL string
30+
BucketName string
31+
Objects []objectWithIcon
32+
AllowDelete bool
33+
Paths []string
34+
CurrentPath string
35+
Endpoint string
36+
CurrentS3 *S3Instance
37+
HasError bool
38+
ErrorMessage string
3539
}
3640

3741
return func(w http.ResponseWriter, r *http.Request) {
@@ -70,6 +74,7 @@ func HandleBucketView(s3 S3, templates fs.FS, allowDelete bool, listRecursive bo
7074
AllowDelete: allowDelete,
7175
Paths: removeEmptyStrings(strings.Split(path, "/")),
7276
CurrentPath: path,
77+
Endpoint: s3.EndpointURL().String(),
7378
}
7479

7580
t, err := template.ParseFS(templates, "layout.html.tmpl", "bucket.html.tmpl")

internal/app/s3manager/bucket_view_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"io"
77
"net/http"
88
"net/http/httptest"
9+
"net/url"
910
"os"
1011
"path/filepath"
1112
"strings"
@@ -185,6 +186,10 @@ func TestHandleBucketView(t *testing.T) {
185186

186187
s3 := &mocks.S3Mock{
187188
ListObjectsFunc: tc.listObjectsFunc,
189+
EndpointURLFunc: func() *url.URL {
190+
u, _ := url.Parse("http://localhost:9000")
191+
return u
192+
},
188193
}
189194

190195
templates := os.DirFS(filepath.Join("..", "..", "..", "web", "template"))

internal/app/s3manager/buckets_view.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ import (
1212
// HandleBucketsView renders all buckets on an HTML page.
1313
func HandleBucketsView(s3 S3, templates fs.FS, allowDelete bool, rootURL string) http.HandlerFunc {
1414
type pageData struct {
15-
RootURL string
16-
Buckets []minio.BucketInfo
17-
AllowDelete bool
15+
RootURL string
16+
Buckets []minio.BucketInfo
17+
AllowDelete bool
18+
CurrentS3 *S3Instance
19+
HasError bool
20+
ErrorMessage string
1821
}
1922

2023
return func(w http.ResponseWriter, r *http.Request) {

internal/app/s3manager/manager_handlers.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,16 @@ func HandleDeleteObjectWithManager(manager *MultiS3Manager) http.HandlerFunc {
137137
}
138138
}
139139

140+
// HandleCheckPublicAccessWithManager checks if an object is publicly accessible using MultiS3Manager.
141+
func HandleCheckPublicAccessWithManager(manager *MultiS3Manager) http.HandlerFunc {
142+
return func(w http.ResponseWriter, r *http.Request) {
143+
s3 := manager.GetCurrentClient()
144+
// Delegate to the original handler with the current S3 client
145+
handler := HandleCheckPublicAccess(s3)
146+
handler(w, r)
147+
}
148+
}
149+
140150
// createBucketViewWithS3Data creates a bucket view handler that includes S3 instance data
141151
func createBucketViewWithS3Data(s3 S3, templates fs.FS, allowDelete bool, listRecursive bool, rootURL string, current *S3Instance, instances []*S3Instance) http.HandlerFunc {
142152
type objectWithIcon struct {
@@ -161,6 +171,7 @@ func createBucketViewWithS3Data(s3 S3, templates fs.FS, allowDelete bool, listRe
161171
S3Instances []*S3Instance
162172
HasError bool
163173
ErrorMessage string
174+
Endpoint string
164175
}
165176

166177
return func(w http.ResponseWriter, r *http.Request) {
@@ -218,6 +229,7 @@ func createBucketViewWithS3Data(s3 S3, templates fs.FS, allowDelete bool, listRe
218229
S3Instances: instances,
219230
HasError: hasError,
220231
ErrorMessage: errorMessage,
232+
Endpoint: s3.EndpointURL().String(),
221233
}
222234

223235
t, err := template.ParseFS(templates, "layout.html.tmpl", "bucket.html.tmpl")

internal/app/s3manager/mocks/s3.go

Lines changed: 37 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package s3manager
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"strings"
8+
9+
"github.com/gorilla/mux"
10+
)
11+
12+
// HandleCheckPublicAccess checks if an object is publicly accessible.
13+
func HandleCheckPublicAccess(s3 S3) http.HandlerFunc {
14+
return func(w http.ResponseWriter, r *http.Request) {
15+
bucketName := mux.Vars(r)["bucketName"]
16+
objectName := mux.Vars(r)["objectName"]
17+
18+
endpoint := s3.EndpointURL().String()
19+
if !strings.HasSuffix(endpoint, "/") {
20+
endpoint += "/"
21+
}
22+
23+
// Construct the public URL
24+
// Note: This assumes path-style access (http://endpoint/bucket/object)
25+
// which is typical for MinIO and generic S3.
26+
url := fmt.Sprintf("%s%s/%s", endpoint, bucketName, objectName)
27+
28+
// Perform a HEAD request to check accessibility without downloading content
29+
resp, err := http.Head(url)
30+
isAccessible := false
31+
statusCode := 0
32+
33+
if err != nil {
34+
// If we can't reach it, it's definitely not accessible or there's a network issue
35+
// We treat this as not accessible for the user's purpose
36+
isAccessible = false
37+
} else {
38+
defer resp.Body.Close()
39+
statusCode = resp.StatusCode
40+
// 200 OK means accessible.
41+
// We might also consider 304 Not Modified as accessible if that ever happens on a fresh HEAD.
42+
isAccessible = resp.StatusCode == http.StatusOK
43+
}
44+
45+
response := map[string]interface{}{
46+
"accessible": isAccessible,
47+
"statusCode": statusCode,
48+
}
49+
50+
w.Header().Set("Content-Type", "application/json")
51+
if err := json.NewEncoder(w).Encode(response); err != nil {
52+
handleHTTPError(w, fmt.Errorf("error encoding JSON: %w", err))
53+
return
54+
}
55+
}
56+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package s3manager_test
2+
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"net/http/httptest"
7+
"net/url"
8+
"testing"
9+
10+
"github.com/cloudlena/s3manager/internal/app/s3manager"
11+
"github.com/cloudlena/s3manager/internal/app/s3manager/mocks"
12+
"github.com/gorilla/mux"
13+
"github.com/matryer/is"
14+
)
15+
16+
func TestHandleCheckPublicAccess(t *testing.T) {
17+
t.Parallel()
18+
19+
cases := []struct {
20+
it string
21+
s3ResponseStatus int
22+
expectAccessible bool
23+
expectStatusCode int
24+
networkError bool
25+
}{
26+
{
27+
it: "reports accessible when S3 returns 200 OK",
28+
s3ResponseStatus: http.StatusOK,
29+
expectAccessible: true,
30+
expectStatusCode: http.StatusOK,
31+
},
32+
{
33+
it: "reports not accessible when S3 returns 403 Forbidden",
34+
s3ResponseStatus: http.StatusForbidden,
35+
expectAccessible: false,
36+
expectStatusCode: http.StatusForbidden,
37+
},
38+
{
39+
it: "reports not accessible when S3 returns 404 Not Found",
40+
s3ResponseStatus: http.StatusNotFound,
41+
expectAccessible: false,
42+
expectStatusCode: http.StatusNotFound,
43+
},
44+
{
45+
it: "reports not accessible on network error",
46+
networkError: true,
47+
expectAccessible: false,
48+
expectStatusCode: 0,
49+
},
50+
}
51+
52+
for _, tc := range cases {
53+
t.Run(tc.it, func(t *testing.T) {
54+
is := is.New(t)
55+
56+
// Start a mock S3 server to respond to the HEAD request
57+
var s3ServerURL string
58+
if !tc.networkError {
59+
s3Server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
60+
is.Equal(http.MethodHead, r.Method)
61+
w.WriteHeader(tc.s3ResponseStatus)
62+
}))
63+
defer s3Server.Close()
64+
s3ServerURL = s3Server.URL
65+
} else {
66+
// Use an invalid port to simulate a network error
67+
s3ServerURL = "http://localhost:0"
68+
}
69+
70+
s3 := &mocks.S3Mock{
71+
EndpointURLFunc: func() *url.URL {
72+
u, _ := url.Parse(s3ServerURL)
73+
return u
74+
},
75+
}
76+
77+
handler := s3manager.HandleCheckPublicAccess(s3)
78+
r := mux.NewRouter()
79+
r.Handle("/api/buckets/{bucketName}/objects/{objectName:.*}/public-access", handler)
80+
81+
req := httptest.NewRequest(http.MethodGet, "/api/buckets/my-bucket/objects/my-file.txt/public-access", nil)
82+
rr := httptest.NewRecorder()
83+
84+
r.ServeHTTP(rr, req)
85+
86+
is.Equal(http.StatusOK, rr.Code)
87+
88+
var response map[string]interface{}
89+
err := json.Unmarshal(rr.Body.Bytes(), &response)
90+
is.NoErr(err)
91+
92+
is.Equal(tc.expectAccessible, response["accessible"])
93+
is.Equal(float64(tc.expectStatusCode), response["statusCode"])
94+
})
95+
}
96+
}

internal/app/s3manager/s3.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type S3 interface {
2121
PutObject(ctx context.Context, bucketName, objectName string, reader io.Reader, objectSize int64, opts minio.PutObjectOptions) (minio.UploadInfo, error)
2222
RemoveBucket(ctx context.Context, bucketName string) error
2323
RemoveObject(ctx context.Context, bucketName, objectName string, opts minio.RemoveObjectOptions) error
24+
EndpointURL() *url.URL
2425
}
2526

2627
// SSEType describes a type of server side encryption.

main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ func main() {
206206
}
207207
r.Handle("/api/buckets/{bucketName}/objects", s3manager.HandleCreateObjectWithManager(s3Manager, sseType)).Methods(http.MethodPost)
208208
r.Handle("/api/buckets/{bucketName}/objects/{objectName:.*}/url", s3manager.HandleGenerateURLWithManager(s3Manager)).Methods(http.MethodGet)
209+
r.Handle("/api/buckets/{bucketName}/objects/{objectName:.*}/public-access", s3manager.HandleCheckPublicAccessWithManager(s3Manager)).Methods(http.MethodGet)
209210
r.Handle("/api/buckets/{bucketName}/objects/{objectName:.*}", s3manager.HandleGetObjectWithManager(s3Manager, configuration.ForceDownload)).Methods(http.MethodGet)
210211
if configuration.AllowDelete {
211212
r.Handle("/api/buckets/{bucketName}/objects/{objectName:.*}", s3manager.HandleDeleteObjectWithManager(s3Manager)).Methods(http.MethodDelete)

0 commit comments

Comments
 (0)