From 05a628cb03c83c00265539e2fae8f3d9d1337963 Mon Sep 17 00:00:00 2001 From: Ronny Date: Fri, 10 Jul 2026 12:21:06 +0200 Subject: [PATCH] feat: support anonymous HTTP bucket access --- README.md | 2 + backend/.env.example | 1 + backend/middleware/auth.go | 20 +++--- backend/router/browse.go | 45 +++++++++++++ backend/router/browse_test.go | 65 +++++++++++++++++++ backend/router/router.go | 7 +- backend/utils/garage.go | 60 +++++++++++++++++ backend/utils/garage_test.go | 33 ++++++++++ docker-compose.yml | 3 +- .../buckets/manage/browse/share-dialog.tsx | 2 +- .../overview/overview-website-access.tsx | 45 +++++++++---- src/pages/buckets/manage/schema.ts | 2 +- 12 files changed, 258 insertions(+), 27 deletions(-) create mode 100644 backend/router/browse_test.go create mode 100644 backend/utils/garage_test.go 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 && ( - Sharing is only available for buckets with enabled website access. + Sharing is only available when HTTP Access is set to Anonymous HTTP. )}
diff --git a/src/pages/buckets/manage/overview/overview-website-access.tsx b/src/pages/buckets/manage/overview/overview-website-access.tsx index 6be03a8..09480df 100644 --- a/src/pages/buckets/manage/overview/overview-website-access.tsx +++ b/src/pages/buckets/manage/overview/overview-website-access.tsx @@ -8,8 +8,14 @@ import { useConfig } from "@/hooks/useConfig"; import { Info, LinkIcon } from "lucide-react"; import Button from "@/components/ui/button"; import { InputField } from "@/components/ui/input"; -import { ToggleField } from "@/components/ui/toggle"; import { useBucketContext } from "../context"; +import FormControl from "@/components/ui/form-control"; +import Select from "@/components/ui/select"; + +const accessOptions = [ + { label: "Private (login required)", value: "private" }, + { label: "Anonymous HTTP", value: "anonymous" }, +] as const; const WebsiteAccessSection = () => { const { bucket: data, bucketName } = useBucketContext(); @@ -17,7 +23,8 @@ const WebsiteAccessSection = () => { const form = useForm({ resolver: zodResolver(websiteConfigSchema), }); - const isEnabled = useWatch({ control: form.control, name: "websiteAccess" }); + const accessMode = useWatch({ control: form.control, name: "accessMode" }); + const isAnonymous = accessMode === "anonymous"; const websitePort = config?.s3_web?.bind_addr?.split(":").pop() || "80"; const rootDomain = config?.s3_web?.root_domain; @@ -25,24 +32,24 @@ const WebsiteAccessSection = () => { const updateMutation = useUpdateBucket(data?.id); const onChange = useDebounce((values: DeepPartial) => { - const data = { - enabled: values.websiteAccess, - indexDocument: values.websiteAccess + const websiteAccess = { + enabled: values.accessMode === "anonymous", + indexDocument: values.accessMode === "anonymous" ? values.websiteConfig?.indexDocument : undefined, - errorDocument: values.websiteAccess + errorDocument: values.accessMode === "anonymous" ? values.websiteConfig?.errorDocument : undefined, }; updateMutation.mutate({ - websiteAccess: data, + websiteAccess, }); }); useEffect(() => { form.reset({ - websiteAccess: data?.websiteAccess, + accessMode: data?.websiteAccess ? "anonymous" : "private", websiteConfig: { indexDocument: data?.websiteConfig?.indexDocument || "index.html", errorDocument: data?.websiteConfig?.errorDocument || "error/400.html", @@ -56,7 +63,7 @@ const WebsiteAccessSection = () => { return (
-

Website Access

+

HTTP Access

- + ( +