From a095ea561172d0d987d3d2b3b54db01f32b0723d Mon Sep 17 00:00:00 2001 From: jpedro-cf Date: Fri, 7 Nov 2025 16:17:22 -0300 Subject: [PATCH 1/4] chore: prettier config following project structure --- .prettierrc | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .prettierrc diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0f74ea6 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": true, + "singleQuote": false, + "printWidth": 80, + "tabWidth": 2 +} From bd35a5ece14b4fd3710f72c46ea11c2b43fd7edb Mon Sep 17 00:00:00 2001 From: jpedro-cf Date: Fri, 7 Nov 2025 16:19:11 -0300 Subject: [PATCH 2/4] feat: added bucket cors requests using aws sdk --- backend/router/buckets.go | 92 +++++++++++++++++++++++++++++++++++++++ backend/router/router.go | 2 + backend/schema/bucket.go | 8 ++++ backend/utils/utils.go | 7 +++ 4 files changed, 109 insertions(+) diff --git a/backend/router/buckets.go b/backend/router/buckets.go index bbd92b2..b1c105a 100644 --- a/backend/router/buckets.go +++ b/backend/router/buckets.go @@ -1,11 +1,16 @@ package router import ( + "context" "encoding/json" "fmt" "khairul169/garage-webui/schema" "khairul169/garage-webui/utils" "net/http" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" ) type Buckets struct{} @@ -52,3 +57,90 @@ func (b *Buckets) GetAll(w http.ResponseWriter, r *http.Request) { utils.ResponseSuccess(w, res) } + +func (b*Buckets) GetCors(w http.ResponseWriter, r *http.Request){ + bucket := r.PathValue("bucket") + + client, err := getS3Client(bucket) + + if err != nil { + utils.ResponseError(w, err) + return + } + + out, err := client.GetBucketCors(context.Background(), &s3.GetBucketCorsInput{ + Bucket: aws.String(bucket), + }) + + res := []schema.BucketCors{} + + for _, rule := range out.CORSRules { + res = append(res, schema.BucketCors{ + AllowedOrigins: rule.AllowedOrigins, + AllowedMethods: rule.AllowedMethods, + AllowedHeaders: rule.AllowedHeaders, + ExposeHeaders: rule.ExposeHeaders, + MaxAgeSeconds: rule.MaxAgeSeconds, + }) + } + + if err != nil { + utils.ResponseError(w, err) + return + } + + utils.ResponseSuccess(w, res) +} + +func (b*Buckets) PutCors(w http.ResponseWriter, r *http.Request){ + bucket := r.PathValue("bucket") + + + var body struct { + Rules []schema.BucketCors `json:"rules"` + } + + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + utils.ResponseError(w, err) + return + } + + client, err := getS3Client(bucket) + + if err != nil { + utils.ResponseError(w, err) + return + } + + cors := make([]types.CORSRule, 0, len(body.Rules)) + + for _, rule := range body.Rules { + if len(rule.AllowedMethods) == 0 || len(rule.AllowedOrigins) == 0 { + utils.ResponseError(w, fmt.Errorf("each CORS rule must have at least one allowed method and origin")) + return + } + + cors = append(cors, types.CORSRule{ + AllowedHeaders: utils.NilIfEmpty(rule.AllowedHeaders), + AllowedMethods: rule.AllowedMethods, // required + AllowedOrigins: rule.AllowedOrigins, // required + ExposeHeaders: utils.NilIfEmpty(rule.ExposeHeaders), + MaxAgeSeconds: rule.MaxAgeSeconds, + }) +} + + res, err := client.PutBucketCors(context.Background(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucket), + CORSConfiguration: &types.CORSConfiguration{ + CORSRules: cors, + }, + }) + + if err != nil { + utils.ResponseError(w, err) + return + } + + utils.ResponseSuccess(w, res) +} + diff --git a/backend/router/router.go b/backend/router/router.go index 1cf3134..4275d93 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -20,6 +20,8 @@ func HandleApiRouter() *http.ServeMux { buckets := &Buckets{} router.HandleFunc("GET /buckets", buckets.GetAll) + router.HandleFunc("GET /buckets/{bucket}/cors", buckets.GetCors) + router.HandleFunc("PUT /buckets/{bucket}/cors", buckets.PutCors) browse := &Browse{} router.HandleFunc("GET /browse/{bucket}", browse.GetObjects) diff --git a/backend/schema/bucket.go b/backend/schema/bucket.go index c809dec..e721d66 100644 --- a/backend/schema/bucket.go +++ b/backend/schema/bucket.go @@ -24,6 +24,14 @@ type Bucket struct { Created string `json:"created"` } +type BucketCors struct { + AllowedOrigins []string `json:"allowedOrigins"` + AllowedMethods []string `json:"allowedMethods"` + AllowedHeaders []string `json:"allowedHeaders"` + ExposeHeaders []string `json:"exposeHeaders"` + MaxAgeSeconds *int32 `json:"maxAgeSeconds"` +} + type LocalAlias struct { AccessKeyID string `json:"accessKeyId"` Alias string `json:"alias"` diff --git a/backend/utils/utils.go b/backend/utils/utils.go index 30c6c6d..30a3b8c 100644 --- a/backend/utils/utils.go +++ b/backend/utils/utils.go @@ -18,6 +18,13 @@ func LastString(str []string) string { return str[len(str)-1] } +func NilIfEmpty[T any](s []T) []T { + if len(s) == 0 { + return nil + } + return s +} + func ResponseError(w http.ResponseWriter, err error) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(err.Error())) From 9344247d19c6c19cdde5c64b2a1b9e517c706028 Mon Sep 17 00:00:00 2001 From: jpedro-cf Date: Fri, 7 Nov 2025 16:21:45 -0300 Subject: [PATCH 3/4] feat: added cors listing & form actions --- src/pages/buckets/manage/context.ts | 3 +- src/pages/buckets/manage/hooks.ts | 23 +- src/pages/buckets/manage/page.tsx | 12 +- .../manage/permissions/access-keys.tsx | 96 ++++++++ .../manage/permissions/cors-configuration.tsx | 231 ++++++++++++++++++ .../manage/permissions/permissions-tab.tsx | 98 +------- src/pages/buckets/manage/schema.ts | 15 ++ src/pages/buckets/types.ts | 8 + 8 files changed, 388 insertions(+), 98 deletions(-) create mode 100644 src/pages/buckets/manage/permissions/access-keys.tsx create mode 100644 src/pages/buckets/manage/permissions/cors-configuration.tsx diff --git a/src/pages/buckets/manage/context.ts b/src/pages/buckets/manage/context.ts index 2d2cbd2..641c025 100644 --- a/src/pages/buckets/manage/context.ts +++ b/src/pages/buckets/manage/context.ts @@ -1,8 +1,9 @@ import { createContext, useContext } from "react"; -import { Bucket } from "../types"; +import { Bucket, BucketCors } from "../types"; export const BucketContext = createContext<{ bucket: Bucket; + cors: BucketCors[]; refetch: () => void; bucketName: string; } | null>(null); diff --git a/src/pages/buckets/manage/hooks.ts b/src/pages/buckets/manage/hooks.ts index 837c8ad..dd589f4 100644 --- a/src/pages/buckets/manage/hooks.ts +++ b/src/pages/buckets/manage/hooks.ts @@ -5,7 +5,8 @@ import { UseMutationOptions, useQuery, } from "@tanstack/react-query"; -import { Bucket, Permissions } from "../types"; +import { Bucket, BucketCors, Permissions } from "../types"; +import { BucketCorsSchema } from "./schema"; export const useBucket = (id?: string | null) => { return useQuery({ @@ -110,3 +111,23 @@ export const useRemoveBucket = ( ...options, }); }; + +export const useBucketCors = (bucketName?: string) => { + return useQuery({ + queryKey: ["bucket_cors", bucketName], + queryFn: () => api.get(`/buckets/${bucketName}/cors`), + enabled: !!bucketName, + }); +}; + +export const useBucketCorsMutation = ( + options?: MutationOptions +) => { + return useMutation({ + mutationFn: (data: BucketCorsSchema) => + api.put(`/buckets/${data.bucketName}/cors`, { + body: { rules: data.rules }, + }), + ...options, + }); +}; diff --git a/src/pages/buckets/manage/page.tsx b/src/pages/buckets/manage/page.tsx index ec2081f..21b8db2 100644 --- a/src/pages/buckets/manage/page.tsx +++ b/src/pages/buckets/manage/page.tsx @@ -1,5 +1,5 @@ import { useParams } from "react-router-dom"; -import { useBucket } from "./hooks"; +import { useBucket, useBucketCors } from "./hooks"; import Page from "@/context/page-context"; import TabView, { Tab } from "@/components/containers/tab-view"; import { @@ -39,6 +39,7 @@ const tabs: Tab[] = [ const ManageBucketPage = () => { const { id } = useParams(); const { data, error, isLoading, refetch } = useBucket(id); + const { data: cors } = useBucketCors(data?.globalAliases[0]); const name = data?.globalAliases[0]; @@ -62,10 +63,15 @@ const ManageBucketPage = () => { )} - {data && ( + {data && cors && (
diff --git a/src/pages/buckets/manage/permissions/access-keys.tsx b/src/pages/buckets/manage/permissions/access-keys.tsx new file mode 100644 index 0000000..6630fa2 --- /dev/null +++ b/src/pages/buckets/manage/permissions/access-keys.tsx @@ -0,0 +1,96 @@ +import { useDenyKey } from "../hooks"; +import { Card, Checkbox, Table } from "react-daisyui"; +import Button from "@/components/ui/button"; +import { Trash } from "lucide-react"; +import AllowKeyDialog from "./allow-key-dialog"; +import { useMemo } from "react"; +import { toast } from "sonner"; +import { handleError } from "@/lib/utils"; +import { useBucketContext } from "../context"; + +const AccessKeyPermissions = () => { + const { bucket, refetch } = useBucketContext(); + + const denyKey = useDenyKey(bucket.id, { + onSuccess: () => { + toast.success("Key removed!"); + refetch(); + }, + onError: handleError, + }); + + const keys = useMemo(() => { + return bucket?.keys.filter( + (key) => + key.permissions.read !== false || + key.permissions.write !== false || + key.permissions.owner !== false + ); + }, [bucket?.keys]); + + const onRemove = (id: string) => { + if (window.confirm("Are you sure you want to remove this key?")) { + denyKey.mutate({ + keyId: id, + permissions: { read: true, write: true, owner: true }, + }); + } + }; + + return ( + +
+ Access Keys + key.accessKeyId)} /> +
+ +
+ + + # + Key + Aliases + Read + Write + Owner + + + + + {keys?.map((key, idx) => ( + + {idx + 1} + {key.name || key.accessKeyId?.substring(0, 8)} + {key.bucketLocalAliases?.join(", ") || "-"} + + + + + + + + + +
+
+
+ ); +}; + +export default AccessKeyPermissions; diff --git a/src/pages/buckets/manage/permissions/cors-configuration.tsx b/src/pages/buckets/manage/permissions/cors-configuration.tsx new file mode 100644 index 0000000..c45ac02 --- /dev/null +++ b/src/pages/buckets/manage/permissions/cors-configuration.tsx @@ -0,0 +1,231 @@ +import { Card, Modal } from "react-daisyui"; +import Button from "@/components/ui/button"; +import { CheckCircle, Plus } from "lucide-react"; +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; +import { useBucketContext } from "../context"; +import { bucketCorsSchema, BucketCorsSchema } from "../schema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Path, useFieldArray, useForm, UseFormReturn } from "react-hook-form"; +import Input, { InputField } from "@/components/ui/input"; +import FormControl from "@/components/ui/form-control"; +import { useDisclosure } from "@/hooks/useDisclosure"; +import Chips from "@/components/ui/chips"; +import { useBucketCorsMutation } from "../hooks"; +import { handleError } from "@/lib/utils"; +import { useQueryClient } from "@tanstack/react-query"; + +const CorsConfiguration = () => { + const queryClient = useQueryClient(); + const { bucketName, cors } = useBucketContext(); + + const { mutate, isPending } = useBucketCorsMutation({ + onSuccess: () => { + toast.success("CORS saved!"); + queryClient.invalidateQueries({ queryKey: ["bucket_cors", bucketName] }); + }, + onError: handleError, + }); + + const form = useForm({ + resolver: zodResolver(bucketCorsSchema), + defaultValues: { + bucketName: bucketName, + rules: + cors.length > 0 + ? cors + : [ + { + allowedHeaders: [], + allowedMethods: [], + allowedOrigins: [], + exposeHeaders: [], + maxAgeSeconds: null, + }, + ], + }, + }); + + const { fields } = useFieldArray({ + control: form.control, + name: "rules", + }); + + function handleSubmit(data: BucketCorsSchema) { + mutate(data); + } + + return ( + +
+
+ + Cors Configuration + + +
+ {fields.map((rule, idx) => ( +
+ ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + +
+ ))} +
+
+ ); +}; + +interface CorsRulesChipsProps { + form: UseFormReturn; + fieldName: Path; + values: string[]; +} + +function CorsRulesChips({ fieldName, form, values }: CorsRulesChipsProps) { + function onRemove(value: string) { + const currentValues = + (form.getValues(fieldName as Path) as string[]) ?? []; + + form.setValue( + fieldName, + currentValues.filter((v) => v !== value) + ); + } + return ( +
+ {values.map((value) => ( + onRemove(value)}> + {value} + + ))} + +
+ ); +} + +interface AddRuleDialogProps { + form: UseFormReturn; + fieldName: Path; +} + +const AddRuleDialog = ({ form, fieldName }: AddRuleDialogProps) => { + const { dialogRef, isOpen, onOpen, onClose } = useDisclosure(); + const inputRef = useRef(null); + + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + inputRef.current.value = ""; + } + }, [isOpen]); + + function onSubmit() { + const value = inputRef.current?.value?.trim(); + if (!value) { + onClose(); + return; + } + + const currentValues = + (form.getValues(fieldName as Path) as string[]) ?? []; + + form.setValue( + fieldName as Path, + [...currentValues, value], + { shouldDirty: true, shouldValidate: true } + ); + + onClose(); + } + + return ( + <> + + + + Add Alias + + + + + + + + + + + + ); +}; + +export default CorsConfiguration; diff --git a/src/pages/buckets/manage/permissions/permissions-tab.tsx b/src/pages/buckets/manage/permissions/permissions-tab.tsx index fa48699..31b21da 100644 --- a/src/pages/buckets/manage/permissions/permissions-tab.tsx +++ b/src/pages/buckets/manage/permissions/permissions-tab.tsx @@ -1,99 +1,11 @@ -import { useDenyKey } from "../hooks"; -import { Card, Checkbox, Table } from "react-daisyui"; -import Button from "@/components/ui/button"; -import { Trash } from "lucide-react"; -import AllowKeyDialog from "./allow-key-dialog"; -import { useMemo } from "react"; -import { toast } from "sonner"; -import { handleError } from "@/lib/utils"; -import { useBucketContext } from "../context"; +import AccessKeyPermissions from "./access-keys"; +import CorsConfiguration from "./cors-configuration"; const PermissionsTab = () => { - const { bucket, refetch } = useBucketContext(); - - const denyKey = useDenyKey(bucket.id, { - onSuccess: () => { - toast.success("Key removed!"); - refetch(); - }, - onError: handleError, - }); - - const keys = useMemo(() => { - return bucket?.keys.filter( - (key) => - key.permissions.read !== false || - key.permissions.write !== false || - key.permissions.owner !== false - ); - }, [bucket?.keys]); - - const onRemove = (id: string) => { - if (window.confirm("Are you sure you want to remove this key?")) { - denyKey.mutate({ - keyId: id, - permissions: { read: true, write: true, owner: true }, - }); - } - }; - return ( -
- -
- Access Keys - key.accessKeyId)} /> -
- -
- - - # - Key - Aliases - Read - Write - Owner - - - - - {keys?.map((key, idx) => ( - - {idx + 1} - {key.name || key.accessKeyId?.substring(0, 8)} - {key.bucketLocalAliases?.join(", ") || "-"} - - - - - - - - - -
-
-
+
+ +
); }; diff --git a/src/pages/buckets/manage/schema.ts b/src/pages/buckets/manage/schema.ts index 0338e17..9ff51ee 100644 --- a/src/pages/buckets/manage/schema.ts +++ b/src/pages/buckets/manage/schema.ts @@ -37,3 +37,18 @@ export const allowKeysSchema = z.object({ }); export type AllowKeysSchema = z.infer; + +export const bucketCorsSchema = z.object({ + bucketName: z.string(), + rules: z.array( + z.object({ + allowedMethods: z.array(z.string()).nullable(), + allowedOrigins: z.array(z.string()).nullable(), + allowedHeaders: z.array(z.string()).nullable(), + exposeHeaders: z.array(z.string()).nullable(), + maxAgeSeconds: z.coerce.number().nullable(), + }) + ), +}); + +export type BucketCorsSchema = z.infer; diff --git a/src/pages/buckets/types.ts b/src/pages/buckets/types.ts index df4b6d7..68d62d5 100644 --- a/src/pages/buckets/types.ts +++ b/src/pages/buckets/types.ts @@ -18,6 +18,14 @@ export type Bucket = { quotas: Quotas; }; +export type BucketCors = { + allowedMethods: string[] | null; + allowedOrigins: string[] | null; + allowedHeaders: string[] | null; + exposeHeaders: string[] | null; + maxAgeSeconds: number | null; +}; + export type LocalAlias = { accessKeyId: string; alias: string; From 48211dd50e247bff3b9e602cbc9549ec035de468 Mon Sep 17 00:00:00 2001 From: jpedro-cf Date: Fri, 7 Nov 2025 20:51:12 -0300 Subject: [PATCH 4/4] feat: must have a 'owner' access key before configuring CORS --- backend/router/browse.go | 30 +++- backend/router/buckets.go | 54 +++--- backend/schema/bucket.go | 13 ++ src/pages/buckets/manage/hooks.ts | 1 + src/pages/buckets/manage/page.tsx | 6 +- .../manage/permissions/allow-key-dialog.tsx | 3 +- .../manage/permissions/cors-configuration.tsx | 164 +++++++++--------- 7 files changed, 154 insertions(+), 117 deletions(-) diff --git a/backend/router/browse.go b/backend/router/browse.go index 5df5acb..6b4ceb1 100644 --- a/backend/router/browse.go +++ b/backend/router/browse.go @@ -33,7 +33,7 @@ func (b *Browse) GetObjects(w http.ResponseWriter, r *http.Request) { limit = 100 } - client, err := getS3Client(bucket) + client, err := getS3Client(bucket, nil) if err != nil { utils.ResponseError(w, err) return @@ -88,7 +88,7 @@ func (b *Browse) GetOneObject(w http.ResponseWriter, r *http.Request) { thumbnail := queryParams.Get("thumb") == "1" download := queryParams.Get("dl") == "1" - client, err := getS3Client(bucket) + client, err := getS3Client(bucket, nil) if err != nil { utils.ResponseError(w, err) return @@ -184,7 +184,7 @@ func (b *Browse) PutObject(w http.ResponseWriter, r *http.Request) { defer file.Close() } - client, err := getS3Client(bucket) + client, err := getS3Client(bucket, nil) if err != nil { utils.ResponseError(w, err) return @@ -220,7 +220,7 @@ func (b *Browse) DeleteObject(w http.ResponseWriter, r *http.Request) { recursive := r.URL.Query().Get("recursive") == "true" isDirectory := strings.HasSuffix(key, "/") - client, err := getS3Client(bucket) + client, err := getS3Client(bucket, nil) if err != nil { utils.ResponseError(w, err) return @@ -284,10 +284,15 @@ func (b *Browse) DeleteObject(w http.ResponseWriter, r *http.Request) { utils.ResponseSuccess(w, res) } -func getBucketCredentials(bucket string) (aws.CredentialsProvider, error) { +func getBucketCredentials(bucket string, permission *string) (aws.CredentialsProvider, error) { cacheKey := fmt.Sprintf("key:%s", bucket) - cacheData := utils.Cache.Get(cacheKey) + // Some CORS operations with the SDK are only achievable using a "owner" access key + if permission != nil { + cacheKey = fmt.Sprintf("key:%s:%s", *permission, bucket) + } + + cacheData := utils.Cache.Get(cacheKey) if cacheData != nil { return cacheData.(aws.CredentialsProvider), nil } @@ -305,7 +310,10 @@ func getBucketCredentials(bucket string) (aws.CredentialsProvider, error) { var key schema.KeyElement for _, k := range bucketData.Keys { - if !k.Permissions.Read || !k.Permissions.Write { + if permission == nil && (!k.Permissions.Read || !k.Permissions.Write){ + continue + } + if permission != nil && !k.Permissions.HasPermission(*permission) { continue } @@ -319,14 +327,18 @@ func getBucketCredentials(bucket string) (aws.CredentialsProvider, error) { break } + if key.AccessKeyID == "" { + return nil, fmt.Errorf("a valid access key was not found for this bucket") + } + credential := credentials.NewStaticCredentialsProvider(key.AccessKeyID, key.SecretAccessKey, "") utils.Cache.Set(cacheKey, credential, time.Hour) return credential, nil } -func getS3Client(bucket string) (*s3.Client, error) { - creds, err := getBucketCredentials(bucket) +func getS3Client(bucket string, permission *string) (*s3.Client, error) { + creds, err := getBucketCredentials(bucket, permission) if err != nil { return nil, fmt.Errorf("cannot get credentials for bucket %s: %w", bucket, err) } diff --git a/backend/router/buckets.go b/backend/router/buckets.go index b1c105a..b4b54e7 100644 --- a/backend/router/buckets.go +++ b/backend/router/buckets.go @@ -61,20 +61,33 @@ func (b *Buckets) GetAll(w http.ResponseWriter, r *http.Request) { func (b*Buckets) GetCors(w http.ResponseWriter, r *http.Request){ bucket := r.PathValue("bucket") - client, err := getS3Client(bucket) + perm := "owner" + client, err := getS3Client(bucket, &perm) if err != nil { utils.ResponseError(w, err) return } - out, err := client.GetBucketCors(context.Background(), &s3.GetBucketCorsInput{ + cors, err := client.GetBucketCors(context.Background(), &s3.GetBucketCorsInput{ Bucket: aws.String(bucket), }) - res := []schema.BucketCors{} + if err != nil { + utils.ResponseError(w, err) + return + } + + var defaultCors = []schema.BucketCors{{ + AllowedOrigins: []string{}, + AllowedMethods: []string{}, + AllowedHeaders: []string{}, + ExposeHeaders: []string{}, + MaxAgeSeconds: nil, + }} - for _, rule := range out.CORSRules { + res := make([]schema.BucketCors, 0, len(cors.CORSRules)) + for _, rule := range cors.CORSRules { res = append(res, schema.BucketCors{ AllowedOrigins: rule.AllowedOrigins, AllowedMethods: rule.AllowedMethods, @@ -84,9 +97,8 @@ func (b*Buckets) GetCors(w http.ResponseWriter, r *http.Request){ }) } - if err != nil { - utils.ResponseError(w, err) - return + if len(res) == 0 { + res = defaultCors } utils.ResponseSuccess(w, res) @@ -94,7 +106,6 @@ func (b*Buckets) GetCors(w http.ResponseWriter, r *http.Request){ func (b*Buckets) PutCors(w http.ResponseWriter, r *http.Request){ bucket := r.PathValue("bucket") - var body struct { Rules []schema.BucketCors `json:"rules"` @@ -105,7 +116,8 @@ func (b*Buckets) PutCors(w http.ResponseWriter, r *http.Request){ return } - client, err := getS3Client(bucket) + perm := "owner" + client, err := getS3Client(bucket, &perm) if err != nil { utils.ResponseError(w, err) @@ -115,19 +127,19 @@ func (b*Buckets) PutCors(w http.ResponseWriter, r *http.Request){ cors := make([]types.CORSRule, 0, len(body.Rules)) for _, rule := range body.Rules { - if len(rule.AllowedMethods) == 0 || len(rule.AllowedOrigins) == 0 { - utils.ResponseError(w, fmt.Errorf("each CORS rule must have at least one allowed method and origin")) - return - } + if len(rule.AllowedMethods) == 0 || len(rule.AllowedOrigins) == 0 { + utils.ResponseError(w, fmt.Errorf("each CORS rule must have at least one allowed method and origin")) + return + } - cors = append(cors, types.CORSRule{ - AllowedHeaders: utils.NilIfEmpty(rule.AllowedHeaders), - AllowedMethods: rule.AllowedMethods, // required - AllowedOrigins: rule.AllowedOrigins, // required - ExposeHeaders: utils.NilIfEmpty(rule.ExposeHeaders), - MaxAgeSeconds: rule.MaxAgeSeconds, - }) -} + cors = append(cors, types.CORSRule{ + AllowedHeaders: utils.NilIfEmpty(rule.AllowedHeaders), + AllowedMethods: rule.AllowedMethods, // required + AllowedOrigins: rule.AllowedOrigins, // required + ExposeHeaders: utils.NilIfEmpty(rule.ExposeHeaders), + MaxAgeSeconds: rule.MaxAgeSeconds, + }) + } res, err := client.PutBucketCors(context.Background(), &s3.PutBucketCorsInput{ Bucket: aws.String(bucket), diff --git a/backend/schema/bucket.go b/backend/schema/bucket.go index e721d66..d26a868 100644 --- a/backend/schema/bucket.go +++ b/backend/schema/bucket.go @@ -60,3 +60,16 @@ type WebsiteConfig struct { IndexDocument string `json:"indexDocument"` ErrorDocument string `json:"errorDocument"` } + +func (p *Permissions) HasPermission(perm string) bool { + switch perm { + case "read": + return p.Read + case "write": + return p.Write + case "owner": + return p.Owner + default: + return false + } +} \ No newline at end of file diff --git a/src/pages/buckets/manage/hooks.ts b/src/pages/buckets/manage/hooks.ts index dd589f4..7449120 100644 --- a/src/pages/buckets/manage/hooks.ts +++ b/src/pages/buckets/manage/hooks.ts @@ -117,6 +117,7 @@ export const useBucketCors = (bucketName?: string) => { queryKey: ["bucket_cors", bucketName], queryFn: () => api.get(`/buckets/${bucketName}/cors`), enabled: !!bucketName, + retry: false, }); }; diff --git a/src/pages/buckets/manage/page.tsx b/src/pages/buckets/manage/page.tsx index 21b8db2..5e489c5 100644 --- a/src/pages/buckets/manage/page.tsx +++ b/src/pages/buckets/manage/page.tsx @@ -39,7 +39,9 @@ const tabs: Tab[] = [ const ManageBucketPage = () => { const { id } = useParams(); const { data, error, isLoading, refetch } = useBucket(id); - const { data: cors } = useBucketCors(data?.globalAliases[0]); + const { data: cors, isLoading: corsLoading } = useBucketCors( + data?.globalAliases[0] + ); const name = data?.globalAliases[0]; @@ -63,7 +65,7 @@ const ManageBucketPage = () => { )} - {data && cors && ( + {data && !corsLoading && (
{ - const { bucket } = useBucketContext(); + const { bucket, bucketName } = useBucketContext(); const { dialogRef, isOpen, onOpen, onClose } = useDisclosure(); const { data: keys } = useKeys(); const form = useForm({ @@ -37,6 +37,7 @@ const AllowKeyDialog = ({ currentKeys }: Props) => { onClose(); toast.success("Key allowed!"); queryClient.invalidateQueries({ queryKey: ["bucket", bucket.id] }); + queryClient.invalidateQueries({ queryKey: ["bucket_cors", bucketName] }); }, onError: handleError, }); diff --git a/src/pages/buckets/manage/permissions/cors-configuration.tsx b/src/pages/buckets/manage/permissions/cors-configuration.tsx index c45ac02..4e22f83 100644 --- a/src/pages/buckets/manage/permissions/cors-configuration.tsx +++ b/src/pages/buckets/manage/permissions/cors-configuration.tsx @@ -1,12 +1,12 @@ -import { Card, Modal } from "react-daisyui"; +import { Alert, Card, Modal } from "react-daisyui"; import Button from "@/components/ui/button"; -import { CheckCircle, Plus } from "lucide-react"; +import { CheckCircle, CircleXIcon, Plus } from "lucide-react"; import { useEffect, useRef } from "react"; import { toast } from "sonner"; import { useBucketContext } from "../context"; import { bucketCorsSchema, BucketCorsSchema } from "../schema"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Path, useFieldArray, useForm, UseFormReturn } from "react-hook-form"; +import { Path, useForm, UseFormReturn } from "react-hook-form"; import Input, { InputField } from "@/components/ui/input"; import FormControl from "@/components/ui/form-control"; import { useDisclosure } from "@/hooks/useDisclosure"; @@ -17,7 +17,7 @@ import { useQueryClient } from "@tanstack/react-query"; const CorsConfiguration = () => { const queryClient = useQueryClient(); - const { bucketName, cors } = useBucketContext(); + const { bucketName, cors, bucket } = useBucketContext(); const { mutate, isPending } = useBucketCorsMutation({ onSuccess: () => { @@ -31,30 +31,18 @@ const CorsConfiguration = () => { resolver: zodResolver(bucketCorsSchema), defaultValues: { bucketName: bucketName, - rules: - cors.length > 0 - ? cors - : [ - { - allowedHeaders: [], - allowedMethods: [], - allowedOrigins: [], - exposeHeaders: [], - maxAgeSeconds: null, - }, - ], + rules: cors, }, }); - const { fields } = useFieldArray({ - control: form.control, - name: "rules", - }); + const fields = form.watch("rules"); function handleSubmit(data: BucketCorsSchema) { mutate(data); } + const allowConfiguration = bucket.keys.some((key) => key.permissions.owner); + return (
@@ -66,74 +54,82 @@ const CorsConfiguration = () => { icon={CheckCircle} color="primary" type="submit" - disabled={isPending} + disabled={isPending || !allowConfiguration} > Save
- {fields.map((rule, idx) => ( -
- ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - -
- ))} + {!allowConfiguration && ( + } className="mt-5"> + + You must configure an access key with owner permissions for this + bucket before setting up CORS. + + + )} + {allowConfiguration && + fields.map((_, idx) => ( +
+ ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + +
+ ))} );