Merge c6d4315c52ed8faceb8e2f901d736ab571a2be57 into ee420fbf2946e9f79977615cee5e29192d7da478

This commit is contained in:
fontexD 2026-07-23 11:25:55 +02:00 committed by GitHub
commit 6bd93b110c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 259 additions and 28 deletions

View File

@ -1,7 +1,7 @@
FROM node:20-slim AS frontend FROM node:20-slim AS frontend
WORKDIR /app WORKDIR /app
RUN npm install -g corepack@latest && corepack use pnpm@latest RUN npm install -g pnpm@9.15.9
COPY package.json pnpm-lock.yaml ./ COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile

View File

@ -55,6 +55,7 @@ services:
environment: environment:
API_BASE_URL: "http://garage:3903" API_BASE_URL: "http://garage:3903"
S3_ENDPOINT_URL: "http://garage:3900" S3_ENDPOINT_URL: "http://garage:3900"
S3_WEB_ENDPOINT_URL: "http://garage:3902"
``` ```
### Without Docker ### Without Docker
@ -148,6 +149,7 @@ Configurable envs:
- `API_ADMIN_KEY`: Admin API key. - `API_ADMIN_KEY`: Admin API key.
- `S3_REGION`: S3 Region. - `S3_REGION`: S3 Region.
- `S3_ENDPOINT_URL`: S3 Endpoint url. - `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 ### Authentication

View File

@ -3,4 +3,5 @@ BASE_PATH=""
AUTH_USER_PASS='username:$2y$10$DSTi9o0uQPEHSNlf66xMEOgm9KgVNBP3vHxA3SK0Xha2EVMb3mTXm' AUTH_USER_PASS='username:$2y$10$DSTi9o0uQPEHSNlf66xMEOgm9KgVNBP3vHxA3SK0Xha2EVMb3mTXm'
API_BASE_URL="http://garage:3903" API_BASE_URL="http://garage:3903"
S3_ENDPOINT_URL="http://garage:3900" S3_ENDPOINT_URL="http://garage:3900"
S3_WEB_ENDPOINT_URL="http://garage:3902"
API_ADMIN_KEY="" API_ADMIN_KEY=""

View File

@ -7,17 +7,8 @@ import (
) )
func AuthMiddleware(next http.Handler) http.Handler { func AuthMiddleware(next http.Handler) http.Handler {
authData := utils.GetEnv("AUTH_USER_PASS", "")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := utils.Session.Get(r, "authenticated") if !IsAuthenticated(r) {
if authData == "" {
next.ServeHTTP(w, r)
return
}
if auth == nil || !auth.(bool) {
utils.ResponseErrorStatus(w, errors.New("unauthorized"), http.StatusUnauthorized) utils.ResponseErrorStatus(w, errors.New("unauthorized"), http.StatusUnauthorized)
return return
} }
@ -25,3 +16,12 @@ func AuthMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r) 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
}

View File

@ -6,9 +6,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"khairul169/garage-webui/middleware"
"khairul169/garage-webui/schema" "khairul169/garage-webui/schema"
"khairul169/garage-webui/utils" "khairul169/garage-webui/utils"
"net/http" "net/http"
"net/http/httputil"
"net/url"
"strconv" "strconv"
"strings" "strings"
"time" "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) { func (b *Browse) GetOneObject(w http.ResponseWriter, r *http.Request) {
if !middleware.IsAuthenticated(r) {
b.getPublicObject(w, r)
return
}
bucket := r.PathValue("bucket") bucket := r.PathValue("bucket")
key := r.PathValue("key") key := r.PathValue("key")
queryParams := r.URL.Query() 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) { func (b *Browse) PutObject(w http.ResponseWriter, r *http.Request) {
bucket := r.PathValue("bucket") bucket := r.PathValue("bucket")
key := r.PathValue("key") key := r.PathValue("key")

View 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())
}
}

View File

@ -11,6 +11,10 @@ func HandleApiRouter() *http.ServeMux {
auth := &Auth{} auth := &Auth{}
mux.HandleFunc("POST /auth/login", auth.Login) 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 := http.NewServeMux()
router.HandleFunc("POST /auth/logout", auth.Logout) router.HandleFunc("POST /auth/logout", auth.Logout)
router.HandleFunc("GET /auth/status", auth.GetStatus) router.HandleFunc("GET /auth/status", auth.GetStatus)
@ -21,9 +25,6 @@ func HandleApiRouter() *http.ServeMux {
buckets := &Buckets{} buckets := &Buckets{}
router.HandleFunc("GET /buckets", buckets.GetAll) 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("PUT /browse/{bucket}/{key...}", browse.PutObject)
router.HandleFunc("DELETE /browse/{bucket}/{key...}", browse.DeleteObject) router.HandleFunc("DELETE /browse/{bucket}/{key...}", browse.DeleteObject)

View File

@ -8,7 +8,9 @@ import (
"io" "io"
"khairul169/garage-webui/schema" "khairul169/garage-webui/schema"
"log" "log"
"net"
"net/http" "net/http"
"net/url"
"os" "os"
"strings" "strings"
@ -74,6 +76,64 @@ func (g *garage) GetS3Endpoint() string {
return endpoint 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 { func (g *garage) GetS3Region() string {
endpoint := os.Getenv("S3_REGION") endpoint := os.Getenv("S3_REGION")
if len(endpoint) > 0 { if len(endpoint) > 0 {

View 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)
}
}

View File

@ -10,7 +10,7 @@ services:
ports: ports:
- 3900:3900 - 3900:3900
- 3901:3901 - 3901:3901
- 3902:3903 - 3902:3902
- 3903:3903 - 3903:3903
webui: webui:
@ -24,3 +24,4 @@ services:
environment: environment:
API_BASE_URL: "http://garage:3903" API_BASE_URL: "http://garage:3903"
S3_ENDPOINT_URL: "http://garage:3900" S3_ENDPOINT_URL: "http://garage:3900"
S3_WEB_ENDPOINT_URL: "http://garage:3902"

View File

@ -42,7 +42,7 @@ const ShareDialog = () => {
{!bucket.websiteAccess && ( {!bucket.websiteAccess && (
<Alert className="mb-4 items-start text-sm"> <Alert className="mb-4 items-start text-sm">
<FileWarningIcon className="mt-1" /> <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> </Alert>
)} )}
<div className="flex flex-row overflow-x-auto pb-2"> <div className="flex flex-row overflow-x-auto pb-2">

View File

@ -8,8 +8,14 @@ import { useConfig } from "@/hooks/useConfig";
import { Info, LinkIcon } from "lucide-react"; import { Info, LinkIcon } from "lucide-react";
import Button from "@/components/ui/button"; import Button from "@/components/ui/button";
import { InputField } from "@/components/ui/input"; import { InputField } from "@/components/ui/input";
import { ToggleField } from "@/components/ui/toggle";
import { useBucketContext } from "../context"; 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 WebsiteAccessSection = () => {
const { bucket: data, bucketName } = useBucketContext(); const { bucket: data, bucketName } = useBucketContext();
@ -17,7 +23,8 @@ const WebsiteAccessSection = () => {
const form = useForm<WebsiteConfigSchema>({ const form = useForm<WebsiteConfigSchema>({
resolver: zodResolver(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 websitePort = config?.s3_web?.bind_addr?.split(":").pop() || "80";
const rootDomain = config?.s3_web?.root_domain; const rootDomain = config?.s3_web?.root_domain;
@ -25,24 +32,24 @@ const WebsiteAccessSection = () => {
const updateMutation = useUpdateBucket(data?.id); const updateMutation = useUpdateBucket(data?.id);
const onChange = useDebounce((values: DeepPartial<WebsiteConfigSchema>) => { const onChange = useDebounce((values: DeepPartial<WebsiteConfigSchema>) => {
const data = { const websiteAccess = {
enabled: values.websiteAccess, enabled: values.accessMode === "anonymous",
indexDocument: values.websiteAccess indexDocument: values.accessMode === "anonymous"
? values.websiteConfig?.indexDocument ? values.websiteConfig?.indexDocument
: undefined, : undefined,
errorDocument: values.websiteAccess errorDocument: values.accessMode === "anonymous"
? values.websiteConfig?.errorDocument ? values.websiteConfig?.errorDocument
: undefined, : undefined,
}; };
updateMutation.mutate({ updateMutation.mutate({
websiteAccess: data, websiteAccess,
}); });
}); });
useEffect(() => { useEffect(() => {
form.reset({ form.reset({
websiteAccess: data?.websiteAccess, accessMode: data?.websiteAccess ? "anonymous" : "private",
websiteConfig: { websiteConfig: {
indexDocument: data?.websiteConfig?.indexDocument || "index.html", indexDocument: data?.websiteConfig?.indexDocument || "index.html",
errorDocument: data?.websiteConfig?.errorDocument || "error/400.html", errorDocument: data?.websiteConfig?.errorDocument || "error/400.html",
@ -56,7 +63,7 @@ const WebsiteAccessSection = () => {
return ( return (
<div className="mt-8"> <div className="mt-8">
<div className="flex flex-row gap-2"> <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 <Button
href="https://garagehq.deuxfleurs.fr/documentation/cookbook/exposing-websites" href="https://garagehq.deuxfleurs.fr/documentation/cookbook/exposing-websites"
target="_blank" target="_blank"
@ -68,9 +75,25 @@ const WebsiteAccessSection = () => {
</Button> </Button>
</div> </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"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<InputField <InputField

View File

@ -7,7 +7,7 @@ export const addAliasSchema = z.object({
export type AddAliasSchema = z.infer<typeof addAliasSchema>; export type AddAliasSchema = z.infer<typeof addAliasSchema>;
export const websiteConfigSchema = z.object({ export const websiteConfigSchema = z.object({
websiteAccess: z.boolean(), accessMode: z.enum(["private", "anonymous"]),
websiteConfig: z websiteConfig: z
.object({ indexDocument: z.string(), errorDocument: z.string() }) .object({ indexDocument: z.string(), errorDocument: z.string() })
.nullish(), .nullish(),