mirror of
https://github.com/khairul169/garage-webui.git
synced 2025-11-30 04:51:04 +07:00
Merge 48211dd50e247bff3b9e602cbc9549ec035de468 into ee420fbf2946e9f79977615cee5e29192d7da478
This commit is contained in:
commit
8b6697455f
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"printWidth": 80,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
@ -33,7 +33,7 @@ func (b *Browse) GetObjects(w http.ResponseWriter, r *http.Request) {
|
|||||||
limit = 100
|
limit = 100
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := getS3Client(bucket)
|
client, err := getS3Client(bucket, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ResponseError(w, err)
|
utils.ResponseError(w, err)
|
||||||
return
|
return
|
||||||
@ -88,7 +88,7 @@ func (b *Browse) GetOneObject(w http.ResponseWriter, r *http.Request) {
|
|||||||
thumbnail := queryParams.Get("thumb") == "1"
|
thumbnail := queryParams.Get("thumb") == "1"
|
||||||
download := queryParams.Get("dl") == "1"
|
download := queryParams.Get("dl") == "1"
|
||||||
|
|
||||||
client, err := getS3Client(bucket)
|
client, err := getS3Client(bucket, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ResponseError(w, err)
|
utils.ResponseError(w, err)
|
||||||
return
|
return
|
||||||
@ -184,7 +184,7 @@ func (b *Browse) PutObject(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer file.Close()
|
defer file.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := getS3Client(bucket)
|
client, err := getS3Client(bucket, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ResponseError(w, err)
|
utils.ResponseError(w, err)
|
||||||
return
|
return
|
||||||
@ -220,7 +220,7 @@ func (b *Browse) DeleteObject(w http.ResponseWriter, r *http.Request) {
|
|||||||
recursive := r.URL.Query().Get("recursive") == "true"
|
recursive := r.URL.Query().Get("recursive") == "true"
|
||||||
isDirectory := strings.HasSuffix(key, "/")
|
isDirectory := strings.HasSuffix(key, "/")
|
||||||
|
|
||||||
client, err := getS3Client(bucket)
|
client, err := getS3Client(bucket, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ResponseError(w, err)
|
utils.ResponseError(w, err)
|
||||||
return
|
return
|
||||||
@ -284,10 +284,15 @@ func (b *Browse) DeleteObject(w http.ResponseWriter, r *http.Request) {
|
|||||||
utils.ResponseSuccess(w, res)
|
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)
|
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 {
|
if cacheData != nil {
|
||||||
return cacheData.(aws.CredentialsProvider), nil
|
return cacheData.(aws.CredentialsProvider), nil
|
||||||
}
|
}
|
||||||
@ -305,7 +310,10 @@ func getBucketCredentials(bucket string) (aws.CredentialsProvider, error) {
|
|||||||
var key schema.KeyElement
|
var key schema.KeyElement
|
||||||
|
|
||||||
for _, k := range bucketData.Keys {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -319,14 +327,18 @@ func getBucketCredentials(bucket string) (aws.CredentialsProvider, error) {
|
|||||||
break
|
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, "")
|
credential := credentials.NewStaticCredentialsProvider(key.AccessKeyID, key.SecretAccessKey, "")
|
||||||
utils.Cache.Set(cacheKey, credential, time.Hour)
|
utils.Cache.Set(cacheKey, credential, time.Hour)
|
||||||
|
|
||||||
return credential, nil
|
return credential, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getS3Client(bucket string) (*s3.Client, error) {
|
func getS3Client(bucket string, permission *string) (*s3.Client, error) {
|
||||||
creds, err := getBucketCredentials(bucket)
|
creds, err := getBucketCredentials(bucket, permission)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot get credentials for bucket %s: %w", bucket, err)
|
return nil, fmt.Errorf("cannot get credentials for bucket %s: %w", bucket, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,16 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"khairul169/garage-webui/schema"
|
"khairul169/garage-webui/schema"
|
||||||
"khairul169/garage-webui/utils"
|
"khairul169/garage-webui/utils"
|
||||||
"net/http"
|
"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{}
|
type Buckets struct{}
|
||||||
@ -52,3 +57,102 @@ func (b *Buckets) GetAll(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
utils.ResponseSuccess(w, res)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,8 @@ func HandleApiRouter() *http.ServeMux {
|
|||||||
|
|
||||||
buckets := &Buckets{}
|
buckets := &Buckets{}
|
||||||
router.HandleFunc("GET /buckets", buckets.GetAll)
|
router.HandleFunc("GET /buckets", buckets.GetAll)
|
||||||
|
router.HandleFunc("GET /buckets/{bucket}/cors", buckets.GetCors)
|
||||||
|
router.HandleFunc("PUT /buckets/{bucket}/cors", buckets.PutCors)
|
||||||
|
|
||||||
browse := &Browse{}
|
browse := &Browse{}
|
||||||
router.HandleFunc("GET /browse/{bucket}", browse.GetObjects)
|
router.HandleFunc("GET /browse/{bucket}", browse.GetObjects)
|
||||||
|
|||||||
@ -24,6 +24,14 @@ type Bucket struct {
|
|||||||
Created string `json:"created"`
|
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 {
|
type LocalAlias struct {
|
||||||
AccessKeyID string `json:"accessKeyId"`
|
AccessKeyID string `json:"accessKeyId"`
|
||||||
Alias string `json:"alias"`
|
Alias string `json:"alias"`
|
||||||
@ -52,3 +60,16 @@ type WebsiteConfig struct {
|
|||||||
IndexDocument string `json:"indexDocument"`
|
IndexDocument string `json:"indexDocument"`
|
||||||
ErrorDocument string `json:"errorDocument"`
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,6 +18,13 @@ func LastString(str []string) string {
|
|||||||
return str[len(str)-1]
|
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) {
|
func ResponseError(w http.ResponseWriter, err error) {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
w.Write([]byte(err.Error()))
|
w.Write([]byte(err.Error()))
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
import { Bucket } from "../types";
|
import { Bucket, BucketCors } from "../types";
|
||||||
|
|
||||||
export const BucketContext = createContext<{
|
export const BucketContext = createContext<{
|
||||||
bucket: Bucket;
|
bucket: Bucket;
|
||||||
|
cors: BucketCors[];
|
||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
bucketName: string;
|
bucketName: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|||||||
@ -5,7 +5,8 @@ import {
|
|||||||
UseMutationOptions,
|
UseMutationOptions,
|
||||||
useQuery,
|
useQuery,
|
||||||
} from "@tanstack/react-query";
|
} 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) => {
|
export const useBucket = (id?: string | null) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@ -110,3 +111,24 @@ export const useRemoveBucket = (
|
|||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useBucketCors = (bucketName?: string) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["bucket_cors", bucketName],
|
||||||
|
queryFn: () => api.get<BucketCors[]>(`/buckets/${bucketName}/cors`),
|
||||||
|
enabled: !!bucketName,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useBucketCorsMutation = (
|
||||||
|
options?: MutationOptions<any, Error, BucketCorsSchema>
|
||||||
|
) => {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (data: BucketCorsSchema) =>
|
||||||
|
api.put(`/buckets/${data.bucketName}/cors`, {
|
||||||
|
body: { rules: data.rules },
|
||||||
|
}),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useBucket } from "./hooks";
|
import { useBucket, useBucketCors } from "./hooks";
|
||||||
import Page from "@/context/page-context";
|
import Page from "@/context/page-context";
|
||||||
import TabView, { Tab } from "@/components/containers/tab-view";
|
import TabView, { Tab } from "@/components/containers/tab-view";
|
||||||
import {
|
import {
|
||||||
@ -39,6 +39,9 @@ const tabs: Tab[] = [
|
|||||||
const ManageBucketPage = () => {
|
const ManageBucketPage = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const { data, error, isLoading, refetch } = useBucket(id);
|
const { data, error, isLoading, refetch } = useBucket(id);
|
||||||
|
const { data: cors, isLoading: corsLoading } = useBucketCors(
|
||||||
|
data?.globalAliases[0]
|
||||||
|
);
|
||||||
|
|
||||||
const name = data?.globalAliases[0];
|
const name = data?.globalAliases[0];
|
||||||
|
|
||||||
@ -62,10 +65,15 @@ const ManageBucketPage = () => {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data && (
|
{data && !corsLoading && (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<BucketContext.Provider
|
<BucketContext.Provider
|
||||||
value={{ bucket: data, refetch, bucketName: name || "" }}
|
value={{
|
||||||
|
bucket: data,
|
||||||
|
cors: cors ?? [],
|
||||||
|
refetch,
|
||||||
|
bucketName: name || "",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<TabView tabs={tabs} className="bg-base-100 h-14 px-1.5" />
|
<TabView tabs={tabs} className="bg-base-100 h-14 px-1.5" />
|
||||||
</BucketContext.Provider>
|
</BucketContext.Provider>
|
||||||
|
|||||||
96
src/pages/buckets/manage/permissions/access-keys.tsx
Normal file
96
src/pages/buckets/manage/permissions/access-keys.tsx
Normal file
@ -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 (
|
||||||
|
<Card className="card-body">
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<Card.Title className="flex-1 truncate">Access Keys</Card.Title>
|
||||||
|
<AllowKeyDialog currentKeys={keys?.map((key) => key.accessKeyId)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table zebra size="sm">
|
||||||
|
<Table.Head>
|
||||||
|
<span>#</span>
|
||||||
|
<span>Key</span>
|
||||||
|
<span>Aliases</span>
|
||||||
|
<span>Read</span>
|
||||||
|
<span>Write</span>
|
||||||
|
<span>Owner</span>
|
||||||
|
<span />
|
||||||
|
</Table.Head>
|
||||||
|
|
||||||
|
<Table.Body>
|
||||||
|
{keys?.map((key, idx) => (
|
||||||
|
<Table.Row>
|
||||||
|
<span>{idx + 1}</span>
|
||||||
|
<span>{key.name || key.accessKeyId?.substring(0, 8)}</span>
|
||||||
|
<span>{key.bucketLocalAliases?.join(", ") || "-"}</span>
|
||||||
|
<span>
|
||||||
|
<Checkbox
|
||||||
|
checked={key.permissions?.read}
|
||||||
|
color="primary"
|
||||||
|
className="cursor-default"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Checkbox
|
||||||
|
checked={key.permissions?.write}
|
||||||
|
color="primary"
|
||||||
|
className="cursor-default"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<Checkbox
|
||||||
|
checked={key.permissions?.owner}
|
||||||
|
color="primary"
|
||||||
|
className="cursor-default"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<Button icon={Trash} onClick={() => onRemove(key.accessKeyId)} />
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccessKeyPermissions;
|
||||||
@ -19,7 +19,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AllowKeyDialog = ({ currentKeys }: Props) => {
|
const AllowKeyDialog = ({ currentKeys }: Props) => {
|
||||||
const { bucket } = useBucketContext();
|
const { bucket, bucketName } = useBucketContext();
|
||||||
const { dialogRef, isOpen, onOpen, onClose } = useDisclosure();
|
const { dialogRef, isOpen, onOpen, onClose } = useDisclosure();
|
||||||
const { data: keys } = useKeys();
|
const { data: keys } = useKeys();
|
||||||
const form = useForm<AllowKeysSchema>({
|
const form = useForm<AllowKeysSchema>({
|
||||||
@ -37,6 +37,7 @@ const AllowKeyDialog = ({ currentKeys }: Props) => {
|
|||||||
onClose();
|
onClose();
|
||||||
toast.success("Key allowed!");
|
toast.success("Key allowed!");
|
||||||
queryClient.invalidateQueries({ queryKey: ["bucket", bucket.id] });
|
queryClient.invalidateQueries({ queryKey: ["bucket", bucket.id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["bucket_cors", bucketName] });
|
||||||
},
|
},
|
||||||
onError: handleError,
|
onError: handleError,
|
||||||
});
|
});
|
||||||
|
|||||||
227
src/pages/buckets/manage/permissions/cors-configuration.tsx
Normal file
227
src/pages/buckets/manage/permissions/cors-configuration.tsx
Normal file
@ -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<BucketCorsSchema>({
|
||||||
|
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 (
|
||||||
|
<Card className="card-body">
|
||||||
|
<form onSubmit={form.handleSubmit(handleSubmit)}>
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<Card.Title className="flex-1 truncate">
|
||||||
|
Cors Configuration
|
||||||
|
</Card.Title>
|
||||||
|
<Button
|
||||||
|
icon={CheckCircle}
|
||||||
|
color="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending || !allowConfiguration}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!allowConfiguration && (
|
||||||
|
<Alert status="warning" icon={<CircleXIcon />} className="mt-5">
|
||||||
|
<span>
|
||||||
|
You must configure an access key with owner permissions for this
|
||||||
|
bucket before setting up CORS.
|
||||||
|
</span>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{allowConfiguration &&
|
||||||
|
fields.map((_, idx) => (
|
||||||
|
<div
|
||||||
|
className="grid md:grid-cols-2 xl:grid-cols-5 gap-5 mt-5"
|
||||||
|
key={idx}
|
||||||
|
>
|
||||||
|
<FormControl
|
||||||
|
form={form}
|
||||||
|
name={`rules.${idx}.allowedHeaders`}
|
||||||
|
title={"Allowed Headers"}
|
||||||
|
render={(field) => (
|
||||||
|
<CorsRulesChips
|
||||||
|
form={form}
|
||||||
|
fieldName={`rules.${idx}.allowedHeaders`}
|
||||||
|
values={(field.value as string[]) ?? []}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
form={form}
|
||||||
|
name={`rules.${idx}.allowedMethods`}
|
||||||
|
title={"Allowed Methods"}
|
||||||
|
render={(field) => (
|
||||||
|
<CorsRulesChips
|
||||||
|
form={form}
|
||||||
|
fieldName={`rules.${idx}.allowedMethods`}
|
||||||
|
values={(field.value as string[]) ?? []}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
form={form}
|
||||||
|
name={`rules.${idx}.allowedOrigins`}
|
||||||
|
title={"Allowed Origins"}
|
||||||
|
render={(field) => (
|
||||||
|
<CorsRulesChips
|
||||||
|
form={form}
|
||||||
|
fieldName={`rules.${idx}.allowedOrigins`}
|
||||||
|
values={(field.value as string[]) ?? []}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormControl
|
||||||
|
form={form}
|
||||||
|
name={`rules.${idx}.exposeHeaders`}
|
||||||
|
title={"Expose Headers"}
|
||||||
|
render={(field) => (
|
||||||
|
<CorsRulesChips
|
||||||
|
form={form}
|
||||||
|
fieldName={`rules.${idx}.exposeHeaders`}
|
||||||
|
values={(field.value as string[]) ?? []}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
form={form}
|
||||||
|
name={`rules.${idx}.maxAgeSeconds`}
|
||||||
|
title="Max age seconds"
|
||||||
|
placeholder="0000"
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CorsRulesChipsProps {
|
||||||
|
form: UseFormReturn<BucketCorsSchema>;
|
||||||
|
fieldName: Path<BucketCorsSchema>;
|
||||||
|
values: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function CorsRulesChips({ fieldName, form, values }: CorsRulesChipsProps) {
|
||||||
|
function onRemove(value: string) {
|
||||||
|
const currentValues =
|
||||||
|
(form.getValues(fieldName as Path<BucketCorsSchema>) as string[]) ?? [];
|
||||||
|
|
||||||
|
form.setValue(
|
||||||
|
fieldName,
|
||||||
|
currentValues.filter((v) => v !== value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row flex-wrap gap-2 mt-2">
|
||||||
|
{values.map((value) => (
|
||||||
|
<Chips key={value} onRemove={() => onRemove(value)}>
|
||||||
|
{value}
|
||||||
|
</Chips>
|
||||||
|
))}
|
||||||
|
<AddRuleDialog form={form} fieldName={fieldName} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddRuleDialogProps {
|
||||||
|
form: UseFormReturn<BucketCorsSchema>;
|
||||||
|
fieldName: Path<BucketCorsSchema>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddRuleDialog = ({ form, fieldName }: AddRuleDialogProps) => {
|
||||||
|
const { dialogRef, isOpen, onOpen, onClose } = useDisclosure();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(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<BucketCorsSchema>) as string[]) ?? [];
|
||||||
|
|
||||||
|
form.setValue(
|
||||||
|
fieldName as Path<BucketCorsSchema>,
|
||||||
|
[...currentValues, value],
|
||||||
|
{ shouldDirty: true, shouldValidate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button type="button" size="sm" onClick={onOpen} icon={Plus}>
|
||||||
|
Add Rule
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal ref={dialogRef} open={isOpen}>
|
||||||
|
<Modal.Header>Add Alias</Modal.Header>
|
||||||
|
|
||||||
|
<Modal.Body>
|
||||||
|
<Input ref={inputRef} className="w-full" />
|
||||||
|
</Modal.Body>
|
||||||
|
|
||||||
|
<Modal.Actions>
|
||||||
|
<Button type="button" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" color="primary" onClick={onSubmit}>
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</Modal.Actions>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CorsConfiguration;
|
||||||
@ -1,99 +1,11 @@
|
|||||||
import { useDenyKey } from "../hooks";
|
import AccessKeyPermissions from "./access-keys";
|
||||||
import { Card, Checkbox, Table } from "react-daisyui";
|
import CorsConfiguration from "./cors-configuration";
|
||||||
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 PermissionsTab = () => {
|
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 (
|
return (
|
||||||
<div>
|
<div className="space-y-5">
|
||||||
<Card className="card-body">
|
<AccessKeyPermissions />
|
||||||
<div className="flex flex-row items-center gap-2">
|
<CorsConfiguration />
|
||||||
<Card.Title className="flex-1 truncate">Access Keys</Card.Title>
|
|
||||||
<AllowKeyDialog currentKeys={keys?.map((key) => key.accessKeyId)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<Table zebra size="sm">
|
|
||||||
<Table.Head>
|
|
||||||
<span>#</span>
|
|
||||||
<span>Key</span>
|
|
||||||
<span>Aliases</span>
|
|
||||||
<span>Read</span>
|
|
||||||
<span>Write</span>
|
|
||||||
<span>Owner</span>
|
|
||||||
<span />
|
|
||||||
</Table.Head>
|
|
||||||
|
|
||||||
<Table.Body>
|
|
||||||
{keys?.map((key, idx) => (
|
|
||||||
<Table.Row>
|
|
||||||
<span>{idx + 1}</span>
|
|
||||||
<span>{key.name || key.accessKeyId?.substring(0, 8)}</span>
|
|
||||||
<span>{key.bucketLocalAliases?.join(", ") || "-"}</span>
|
|
||||||
<span>
|
|
||||||
<Checkbox
|
|
||||||
checked={key.permissions?.read}
|
|
||||||
color="primary"
|
|
||||||
className="cursor-default"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<Checkbox
|
|
||||||
checked={key.permissions?.write}
|
|
||||||
color="primary"
|
|
||||||
className="cursor-default"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<Checkbox
|
|
||||||
checked={key.permissions?.owner}
|
|
||||||
color="primary"
|
|
||||||
className="cursor-default"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
icon={Trash}
|
|
||||||
onClick={() => onRemove(key.accessKeyId)}
|
|
||||||
/>
|
|
||||||
</Table.Row>
|
|
||||||
))}
|
|
||||||
</Table.Body>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -37,3 +37,18 @@ export const allowKeysSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type AllowKeysSchema = z.infer<typeof allowKeysSchema>;
|
export type AllowKeysSchema = z.infer<typeof allowKeysSchema>;
|
||||||
|
|
||||||
|
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<typeof bucketCorsSchema>;
|
||||||
|
|||||||
@ -18,6 +18,14 @@ export type Bucket = {
|
|||||||
quotas: Quotas;
|
quotas: Quotas;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type BucketCors = {
|
||||||
|
allowedMethods: string[] | null;
|
||||||
|
allowedOrigins: string[] | null;
|
||||||
|
allowedHeaders: string[] | null;
|
||||||
|
exposeHeaders: string[] | null;
|
||||||
|
maxAgeSeconds: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type LocalAlias = {
|
export type LocalAlias = {
|
||||||
accessKeyId: string;
|
accessKeyId: string;
|
||||||
alias: string;
|
alias: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user