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 +} 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 bbd92b2..b4b54e7 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,102 @@ 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") + + perm := "owner" + client, err := getS3Client(bucket, &perm) + + if err != nil { + utils.ResponseError(w, err) + return + } + + cors, err := client.GetBucketCors(context.Background(), &s3.GetBucketCorsInput{ + Bucket: aws.String(bucket), + }) + + if err != nil { + utils.ResponseError(w, err) + return + } + + var defaultCors = []schema.BucketCors{{ + AllowedOrigins: []string{}, + AllowedMethods: []string{}, + AllowedHeaders: []string{}, + ExposeHeaders: []string{}, + MaxAgeSeconds: nil, + }} + + res := make([]schema.BucketCors, 0, len(cors.CORSRules)) + for _, rule := range cors.CORSRules { + res = append(res, schema.BucketCors{ + AllowedOrigins: rule.AllowedOrigins, + AllowedMethods: rule.AllowedMethods, + AllowedHeaders: rule.AllowedHeaders, + ExposeHeaders: rule.ExposeHeaders, + MaxAgeSeconds: rule.MaxAgeSeconds, + }) + } + + if len(res) == 0 { + res = defaultCors + } + + 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 + } + + perm := "owner" + client, err := getS3Client(bucket, &perm) + + 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..d26a868 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"` @@ -52,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/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())) 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..7449120 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,24 @@ export const useRemoveBucket = ( ...options, }); }; + +export const useBucketCors = (bucketName?: string) => { + return useQuery({ + queryKey: ["bucket_cors", bucketName], + queryFn: () => api.get(`/buckets/${bucketName}/cors`), + enabled: !!bucketName, + retry: false, + }); +}; + +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..5e489c5 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,9 @@ const tabs: Tab[] = [ const ManageBucketPage = () => { const { id } = useParams(); const { data, error, isLoading, refetch } = useBucket(id); + const { data: cors, isLoading: corsLoading } = useBucketCors( + data?.globalAliases[0] + ); const name = data?.globalAliases[0]; @@ -62,10 +65,15 @@ const ManageBucketPage = () => { )} - {data && ( + {data && !corsLoading && (
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/allow-key-dialog.tsx b/src/pages/buckets/manage/permissions/allow-key-dialog.tsx index e149a3b..1ac6d02 100644 --- a/src/pages/buckets/manage/permissions/allow-key-dialog.tsx +++ b/src/pages/buckets/manage/permissions/allow-key-dialog.tsx @@ -19,7 +19,7 @@ type Props = { }; const AllowKeyDialog = ({ currentKeys }: Props) => { - 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 new file mode 100644 index 0000000..4e22f83 --- /dev/null +++ b/src/pages/buckets/manage/permissions/cors-configuration.tsx @@ -0,0 +1,227 @@ +import { Alert, Card, Modal } from "react-daisyui"; +import Button from "@/components/ui/button"; +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, 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, bucket } = 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, + }, + }); + + const fields = form.watch("rules"); + + function handleSubmit(data: BucketCorsSchema) { + mutate(data); + } + + const allowConfiguration = bucket.keys.some((key) => key.permissions.owner); + + return ( + +
+
+ + Cors Configuration + + +
+ {!allowConfiguration && ( + } className="mt-5"> + + You must configure an access key with owner permissions for this + bucket before setting up CORS. + + + )} + {allowConfiguration && + fields.map((_, 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;