mirror of
https://github.com/khairul169/garage-webui.git
synced 2026-07-28 11:00:19 +07:00
feat: support anonymous HTTP bucket access
This commit is contained in:
parent
ee420fbf29
commit
05a628cb03
@ -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
|
||||
|
||||
|
||||
@ -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=""
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
65
backend/router/browse_test.go
Normal file
65
backend/router/browse_test.go
Normal file
@ -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())
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
33
backend/utils/garage_test.go
Normal file
33
backend/utils/garage_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -42,7 +42,7 @@ const ShareDialog = () => {
|
||||
{!bucket.websiteAccess && (
|
||||
<Alert className="mb-4 items-start text-sm">
|
||||
<FileWarningIcon className="mt-1" />
|
||||
Sharing is only available for buckets with enabled website access.
|
||||
Sharing is only available when HTTP Access is set to Anonymous HTTP.
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex flex-row overflow-x-auto pb-2">
|
||||
|
||||
@ -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<WebsiteConfigSchema>({
|
||||
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<WebsiteConfigSchema>) => {
|
||||
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 (
|
||||
<div className="mt-8">
|
||||
<div className="flex flex-row gap-2">
|
||||
<p className="label label-text py-0 grow-0">Website Access</p>
|
||||
<p className="label label-text py-0 grow-0">HTTP Access</p>
|
||||
<Button
|
||||
href="https://garagehq.deuxfleurs.fr/documentation/cookbook/exposing-websites"
|
||||
target="_blank"
|
||||
@ -68,9 +75,25 @@ const WebsiteAccessSection = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ToggleField form={form} name="websiteAccess" label="Enabled" />
|
||||
<FormControl
|
||||
form={form}
|
||||
name="accessMode"
|
||||
className="mt-2"
|
||||
render={(field) => (
|
||||
<Select
|
||||
{...field}
|
||||
value={accessOptions.find((option) => option.value === field.value)}
|
||||
options={accessOptions}
|
||||
onChange={(option) =>
|
||||
field.onChange(
|
||||
(option as { value?: "private" | "anonymous" } | null)?.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isEnabled && (
|
||||
{isAnonymous && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<InputField
|
||||
|
||||
@ -7,7 +7,7 @@ export const addAliasSchema = z.object({
|
||||
export type AddAliasSchema = z.infer<typeof addAliasSchema>;
|
||||
|
||||
export const websiteConfigSchema = z.object({
|
||||
websiteAccess: z.boolean(),
|
||||
accessMode: z.enum(["private", "anonymous"]),
|
||||
websiteConfig: z
|
||||
.object({ indexDocument: z.string(), errorDocument: z.string() })
|
||||
.nullish(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user