diff --git a/README.md b/README.md
index ed0c42e..b970161 100644
--- a/README.md
+++ b/README.md
@@ -55,6 +55,7 @@ services:
environment:
API_BASE_URL: "http://garage:3903"
S3_ENDPOINT_URL: "http://garage:3900"
+ S3_WEB_ENDPOINT_URL: "http://garage:3902"
```
### Without Docker
@@ -148,6 +149,7 @@ Configurable envs:
- `API_ADMIN_KEY`: Admin API key.
- `S3_REGION`: S3 Region.
- `S3_ENDPOINT_URL`: S3 Endpoint url.
+- `S3_WEB_ENDPOINT_URL`: Garage web endpoint used for anonymous reads of objects in buckets with website access enabled.
### Authentication
diff --git a/backend/.env.example b/backend/.env.example
index f5fa4af..106c0a3 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -3,4 +3,5 @@ BASE_PATH=""
AUTH_USER_PASS='username:$2y$10$DSTi9o0uQPEHSNlf66xMEOgm9KgVNBP3vHxA3SK0Xha2EVMb3mTXm'
API_BASE_URL="http://garage:3903"
S3_ENDPOINT_URL="http://garage:3900"
+S3_WEB_ENDPOINT_URL="http://garage:3902"
API_ADMIN_KEY=""
diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go
index 9c8bbc1..17e273c 100644
--- a/backend/middleware/auth.go
+++ b/backend/middleware/auth.go
@@ -7,17 +7,8 @@ import (
)
func AuthMiddleware(next http.Handler) http.Handler {
- authData := utils.GetEnv("AUTH_USER_PASS", "")
-
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- auth := utils.Session.Get(r, "authenticated")
-
- if authData == "" {
- next.ServeHTTP(w, r)
- return
- }
-
- if auth == nil || !auth.(bool) {
+ if !IsAuthenticated(r) {
utils.ResponseErrorStatus(w, errors.New("unauthorized"), http.StatusUnauthorized)
return
}
@@ -25,3 +16,12 @@ func AuthMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
+
+func IsAuthenticated(r *http.Request) bool {
+ if utils.GetEnv("AUTH_USER_PASS", "") == "" {
+ return true
+ }
+
+ auth, ok := utils.Session.Get(r, "authenticated").(bool)
+ return ok && auth
+}
diff --git a/backend/router/browse.go b/backend/router/browse.go
index 5df5acb..f590d2c 100644
--- a/backend/router/browse.go
+++ b/backend/router/browse.go
@@ -6,9 +6,12 @@ import (
"errors"
"fmt"
"io"
+ "khairul169/garage-webui/middleware"
"khairul169/garage-webui/schema"
"khairul169/garage-webui/utils"
"net/http"
+ "net/http/httputil"
+ "net/url"
"strconv"
"strings"
"time"
@@ -81,6 +84,11 @@ func (b *Browse) GetObjects(w http.ResponseWriter, r *http.Request) {
}
func (b *Browse) GetOneObject(w http.ResponseWriter, r *http.Request) {
+ if !middleware.IsAuthenticated(r) {
+ b.getPublicObject(w, r)
+ return
+ }
+
bucket := r.PathValue("bucket")
key := r.PathValue("key")
queryParams := r.URL.Query()
@@ -169,6 +177,43 @@ func (b *Browse) GetOneObject(w http.ResponseWriter, r *http.Request) {
}
}
+func (b *Browse) getPublicObject(w http.ResponseWriter, r *http.Request) {
+ target, err := url.Parse(utils.Garage.GetS3WebEndpoint())
+ if err != nil || target.Scheme == "" || target.Host == "" {
+ utils.ResponseErrorStatus(w, errors.New("invalid Garage web endpoint"), http.StatusBadGateway)
+ return
+ }
+
+ bucket := r.PathValue("bucket")
+ key := r.PathValue("key")
+ download := r.URL.Query().Get("dl") == "1"
+
+ proxy := &httputil.ReverseProxy{
+ Rewrite: func(proxyRequest *httputil.ProxyRequest) {
+ proxyRequest.Out.URL.Scheme = target.Scheme
+ proxyRequest.Out.URL.Host = target.Host
+ proxyRequest.Out.URL.Path = strings.TrimSuffix(target.Path, "/") + "/" + strings.TrimPrefix(key, "/")
+ proxyRequest.Out.URL.RawPath = ""
+ proxyRequest.Out.URL.RawQuery = ""
+ proxyRequest.Out.Host = bucket
+ proxyRequest.Out.Header.Del("Authorization")
+ proxyRequest.Out.Header.Del("Cookie")
+ },
+ ModifyResponse: func(response *http.Response) error {
+ if download && response.StatusCode >= 200 && response.StatusCode < 300 {
+ parts := strings.Split(key, "/")
+ response.Header.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", parts[len(parts)-1]))
+ }
+ return nil
+ },
+ ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) {
+ utils.ResponseErrorStatus(w, fmt.Errorf("cannot fetch public object: %w", err), http.StatusBadGateway)
+ },
+ }
+
+ proxy.ServeHTTP(w, r)
+}
+
func (b *Browse) PutObject(w http.ResponseWriter, r *http.Request) {
bucket := r.PathValue("bucket")
key := r.PathValue("key")
diff --git a/backend/router/browse_test.go b/backend/router/browse_test.go
new file mode 100644
index 0000000..b3fa0f9
--- /dev/null
+++ b/backend/router/browse_test.go
@@ -0,0 +1,65 @@
+package router
+
+import (
+ "io"
+ "khairul169/garage-webui/utils"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestAnonymousObjectUsesGarageWebEndpoint(t *testing.T) {
+ webEndpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Host != "public-bucket" {
+ t.Errorf("expected bucket host, got %q", r.Host)
+ }
+ if r.URL.Path != "/folder/file name.txt" {
+ t.Errorf("expected object path, got %q", r.URL.Path)
+ }
+ if r.URL.RawQuery != "" {
+ t.Errorf("expected UI query parameters to be removed, got %q", r.URL.RawQuery)
+ }
+ if r.Header.Get("Authorization") != "" {
+ t.Error("authorization header was forwarded to the public endpoint")
+ }
+ if r.Header.Get("Cookie") != "" {
+ t.Error("cookie header was forwarded to the public endpoint")
+ }
+
+ w.Header().Set("Content-Type", "text/plain")
+ _, _ = io.WriteString(w, "public content")
+ }))
+ defer webEndpoint.Close()
+
+ t.Setenv("AUTH_USER_PASS", "user:hash")
+ t.Setenv("S3_WEB_ENDPOINT_URL", webEndpoint.URL)
+ sessionManager := utils.InitSessionManager()
+ handler := sessionManager.LoadAndSave(HandleApiRouter())
+
+ request := httptest.NewRequest(http.MethodGet, "/browse/public-bucket/folder/file%20name.txt?view=1", nil)
+ request.Header.Set("Authorization", "Bearer private-token")
+ request.Header.Set("Cookie", "dashboard=private-session")
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("expected status 200, got %d: %s", response.Code, response.Body.String())
+ }
+ if response.Body.String() != "public content" {
+ t.Errorf("expected proxied object body, got %q", response.Body.String())
+ }
+}
+
+func TestAnonymousBucketListingStillRequiresAuthentication(t *testing.T) {
+ t.Setenv("AUTH_USER_PASS", "user:hash")
+ sessionManager := utils.InitSessionManager()
+ handler := sessionManager.LoadAndSave(HandleApiRouter())
+
+ request := httptest.NewRequest(http.MethodGet, "/browse/private-bucket", nil)
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, request)
+
+ if response.Code != http.StatusUnauthorized {
+ t.Fatalf("expected status 401, got %d: %s", response.Code, response.Body.String())
+ }
+}
diff --git a/backend/router/router.go b/backend/router/router.go
index 1cf3134..fbf6323 100644
--- a/backend/router/router.go
+++ b/backend/router/router.go
@@ -11,6 +11,10 @@ func HandleApiRouter() *http.ServeMux {
auth := &Auth{}
mux.HandleFunc("POST /auth/login", auth.Login)
+ browse := &Browse{}
+ mux.Handle("GET /browse/{bucket}", middleware.AuthMiddleware(http.HandlerFunc(browse.GetObjects)))
+ mux.HandleFunc("GET /browse/{bucket}/{key...}", browse.GetOneObject)
+
router := http.NewServeMux()
router.HandleFunc("POST /auth/logout", auth.Logout)
router.HandleFunc("GET /auth/status", auth.GetStatus)
@@ -21,9 +25,6 @@ func HandleApiRouter() *http.ServeMux {
buckets := &Buckets{}
router.HandleFunc("GET /buckets", buckets.GetAll)
- browse := &Browse{}
- router.HandleFunc("GET /browse/{bucket}", browse.GetObjects)
- router.HandleFunc("GET /browse/{bucket}/{key...}", browse.GetOneObject)
router.HandleFunc("PUT /browse/{bucket}/{key...}", browse.PutObject)
router.HandleFunc("DELETE /browse/{bucket}/{key...}", browse.DeleteObject)
diff --git a/backend/utils/garage.go b/backend/utils/garage.go
index bde29ab..9e52586 100644
--- a/backend/utils/garage.go
+++ b/backend/utils/garage.go
@@ -8,7 +8,9 @@ import (
"io"
"khairul169/garage-webui/schema"
"log"
+ "net"
"net/http"
+ "net/url"
"os"
"strings"
@@ -74,6 +76,64 @@ func (g *garage) GetS3Endpoint() string {
return endpoint
}
+func (g *garage) GetS3WebEndpoint() string {
+ endpoint := os.Getenv("S3_WEB_ENDPOINT_URL")
+ if len(endpoint) > 0 {
+ return endpoint
+ }
+
+ port := endpointPort(g.Config.S3Web.BindAddr, "3902")
+ for _, candidate := range []string{
+ os.Getenv("S3_ENDPOINT_URL"),
+ os.Getenv("API_BASE_URL"),
+ g.Config.RPCPublicAddr,
+ } {
+ host, scheme := endpointHost(candidate)
+ if host == "" || isTemplateValue(host) {
+ continue
+ }
+
+ return fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(host, port))
+ }
+
+ return ""
+}
+
+func endpointHost(endpoint string) (string, string) {
+ if endpoint == "" {
+ return "", "http"
+ }
+
+ scheme := "http"
+ value := endpoint
+ if !strings.Contains(value, "://") {
+ value = "//" + value
+ }
+
+ parsed, err := url.Parse(value)
+ if err != nil {
+ return "", scheme
+ }
+ if parsed.Scheme != "" {
+ scheme = parsed.Scheme
+ }
+
+ return parsed.Hostname(), scheme
+}
+
+func endpointPort(bindAddr string, defaultPort string) string {
+ _, port, err := net.SplitHostPort(bindAddr)
+ if err == nil && port != "" {
+ return port
+ }
+
+ return defaultPort
+}
+
+func isTemplateValue(value string) bool {
+ return strings.HasPrefix(value, "__") && strings.HasSuffix(value, "__")
+}
+
func (g *garage) GetS3Region() string {
endpoint := os.Getenv("S3_REGION")
if len(endpoint) > 0 {
diff --git a/backend/utils/garage_test.go b/backend/utils/garage_test.go
new file mode 100644
index 0000000..552c3df
--- /dev/null
+++ b/backend/utils/garage_test.go
@@ -0,0 +1,33 @@
+package utils
+
+import (
+ "khairul169/garage-webui/schema"
+ "testing"
+)
+
+func TestGetS3WebEndpointUsesS3ServiceHost(t *testing.T) {
+ t.Setenv("S3_WEB_ENDPOINT_URL", "")
+ t.Setenv("S3_ENDPOINT_URL", "http://garage:3900")
+ t.Setenv("API_BASE_URL", "http://garage-admin:3903")
+
+ garage := &garage{Config: schema.Config{
+ RPCPublicAddr: "__RPC_PUBLIC_ADDR__:3901",
+ S3Web: schema.S3Web{
+ BindAddr: "[::]:3902",
+ },
+ }}
+
+ if endpoint := garage.GetS3WebEndpoint(); endpoint != "http://garage:3902" {
+ t.Fatalf("expected S3 service host, got %q", endpoint)
+ }
+}
+
+func TestGetS3WebEndpointPrefersExplicitEndpoint(t *testing.T) {
+ t.Setenv("S3_WEB_ENDPOINT_URL", "http://garage-web.storage:8080")
+ t.Setenv("S3_ENDPOINT_URL", "http://garage:3900")
+
+ garage := &garage{}
+ if endpoint := garage.GetS3WebEndpoint(); endpoint != "http://garage-web.storage:8080" {
+ t.Fatalf("expected explicit web endpoint, got %q", endpoint)
+ }
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 0a233bb..268a5ea 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,7 +10,7 @@ services:
ports:
- 3900:3900
- 3901:3901
- - 3902:3903
+ - 3902:3902
- 3903:3903
webui:
@@ -24,3 +24,4 @@ services:
environment:
API_BASE_URL: "http://garage:3903"
S3_ENDPOINT_URL: "http://garage:3900"
+ S3_WEB_ENDPOINT_URL: "http://garage:3902"
diff --git a/src/pages/buckets/manage/browse/share-dialog.tsx b/src/pages/buckets/manage/browse/share-dialog.tsx
index fc98e98..e8a0d81 100644
--- a/src/pages/buckets/manage/browse/share-dialog.tsx
+++ b/src/pages/buckets/manage/browse/share-dialog.tsx
@@ -42,7 +42,7 @@ const ShareDialog = () => {
{!bucket.websiteAccess && (
Website Access
+HTTP Access