mirror of
https://github.com/rustfs/console.git
synced 2026-08-28 19:47:21 +08:00
feat: add bucket cors configuration
Refs: https://github.com/rustfs/rustfs/issues/2330
This commit is contained in:
+146
-2
@@ -12,7 +12,7 @@ import { Switch } from "@/components/ui/switch"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Field, FieldContent, FieldLabel } from "@/components/ui/field"
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel } from "@/components/ui/field"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { useMessage } from "@/lib/feedback/message"
|
||||
import { formatBytes, getBytes } from "@/lib/functions"
|
||||
import { detectBucketPolicy, setBucketPolicy, type BucketPolicyType } from "@/lib/bucket-policy"
|
||||
import { stringifyBucketCorsConfig, validateBucketCorsJson, type BucketCorsConfiguration } from "@/lib/bucket-cors"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface BucketInfoProps {
|
||||
@@ -69,6 +70,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
const canDeleteBucketPolicy = canCapability("bucket.policy.delete", bucketContext)
|
||||
const canEditBucketPolicy = canPutBucketPolicy || canDeleteBucketPolicy
|
||||
const canEditEncryption = canCapability("bucket.encryption.edit", bucketContext)
|
||||
const canEditCors = canCapability("bucket.cors.edit", bucketContext)
|
||||
const canEditTags = canCapability("bucket.tag.edit", bucketContext)
|
||||
const canEditVersioning = canCapability("bucket.versioning.edit", bucketContext)
|
||||
const canEditQuota = canCapability("bucket.quota.edit", bucketContext)
|
||||
@@ -77,6 +79,7 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
const [policy, setPolicy] = React.useState<string | null>(null)
|
||||
const [policyType, setPolicyType] = React.useState<BucketPolicyType | "custom">("private")
|
||||
const [encryption, setEncryption] = React.useState<Record<string, unknown> | null>(null)
|
||||
const [corsConfig, setCorsConfig] = React.useState<BucketCorsConfiguration | null>(null)
|
||||
const [tags, setTags] = React.useState<Tag[]>([])
|
||||
const [objectLock, setObjectLock] = React.useState<boolean | null>(null)
|
||||
const [versioning, setVersioning] = React.useState<string | null>(null)
|
||||
@@ -97,6 +100,10 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
const [encryptFormKmsKeyId, setEncryptFormKmsKeyId] = React.useState("")
|
||||
const [kmsKeyOptions, setKmsKeyOptions] = React.useState<{ label: string; value: string }[]>([])
|
||||
|
||||
const [showCorsModal, setShowCorsModal] = React.useState(false)
|
||||
const [corsFormEnabled, setCorsFormEnabled] = React.useState(false)
|
||||
const [corsFormContent, setCorsFormContent] = React.useState(stringifyBucketCorsConfig())
|
||||
|
||||
const [showTagModal, setShowTagModal] = React.useState(false)
|
||||
const [tagFormKey, setTagFormKey] = React.useState("")
|
||||
const [tagFormValue, setTagFormValue] = React.useState("")
|
||||
@@ -113,13 +120,19 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
const [retentionPeriodInput, setRetentionPeriodInput] = React.useState("")
|
||||
const [deleteTagIndex, setDeleteTagIndex] = React.useState<number | null>(null)
|
||||
|
||||
const corsValidation = React.useMemo(
|
||||
() => (corsFormEnabled ? validateBucketCorsJson(corsFormContent) : { config: null, error: null }),
|
||||
[corsFormContent, corsFormEnabled],
|
||||
)
|
||||
|
||||
const fetchData = React.useCallback(async () => {
|
||||
if (!bucketName) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const [p, e, tagResp, lockResp, verResp, quotaResp] = await Promise.all([
|
||||
const [p, e, corsResp, tagResp, lockResp, verResp, quotaResp] = await Promise.all([
|
||||
bucketApi.getBucketPolicy(bucketName).catch(() => null),
|
||||
bucketApi.getBucketEncryption(bucketName).catch(() => null),
|
||||
bucketApi.getBucketCors(bucketName).catch(() => null),
|
||||
bucketApi.getBucketTagging(bucketName).catch(() => null),
|
||||
bucketApi.getObjectLockConfiguration(bucketName).catch(() => null),
|
||||
bucketApi.getBucketVersioning(bucketName).catch(() => null),
|
||||
@@ -146,6 +159,11 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
}
|
||||
|
||||
setEncryption(e as unknown as Record<string, unknown>)
|
||||
setCorsConfig(
|
||||
((corsResp as BucketCorsConfiguration | null)?.CORSRules?.length ?? 0) > 0
|
||||
? (corsResp as BucketCorsConfiguration)
|
||||
: null,
|
||||
)
|
||||
setTags(
|
||||
(tagResp as { TagSet?: Array<{ Key?: string; Value?: string }> })?.TagSet?.map((x) => ({
|
||||
Key: x.Key ?? "",
|
||||
@@ -302,6 +320,38 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const openCorsModal = () => {
|
||||
if (!canEditCors) return
|
||||
setCorsFormEnabled(Boolean(corsConfig?.CORSRules?.length))
|
||||
setCorsFormContent(stringifyBucketCorsConfig(corsConfig))
|
||||
setShowCorsModal(true)
|
||||
}
|
||||
|
||||
const submitCors = async () => {
|
||||
if (!canEditCors) return
|
||||
|
||||
try {
|
||||
if (!corsFormEnabled) {
|
||||
if (corsConfig?.CORSRules?.length) {
|
||||
await bucketApi.deleteBucketCors(bucketName)
|
||||
}
|
||||
} else {
|
||||
if (corsValidation.error || !corsValidation.config) {
|
||||
message.error(corsValidation.error ?? t("Invalid CORS configuration"))
|
||||
return
|
||||
}
|
||||
|
||||
await bucketApi.putBucketCors(bucketName, corsValidation.config)
|
||||
}
|
||||
|
||||
message.success(t("Edit Success"))
|
||||
setShowCorsModal(false)
|
||||
await fetchData()
|
||||
} catch (err) {
|
||||
message.error(`${t("Edit Failed")}: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const openTagModal = (index = -1) => {
|
||||
if (!canEditTags) return
|
||||
setEditingTagIndex(index)
|
||||
@@ -460,6 +510,9 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
: policyType
|
||||
|
||||
const encryptionLabel = parseEncryptionLabel(encryption as Parameters<typeof parseEncryptionLabel>[0])
|
||||
const corsRules = corsConfig?.CORSRules ?? []
|
||||
const corsMethods = Array.from(new Set(corsRules.flatMap((rule) => rule.AllowedMethods ?? [])))
|
||||
const corsOrigins = Array.from(new Set(corsRules.flatMap((rule) => rule.AllowedOrigins ?? [])))
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -508,6 +561,46 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
</ItemHeader>
|
||||
</Item>
|
||||
|
||||
{/* CORS */}
|
||||
<Item variant="outline" className="flex-col items-stretch gap-4">
|
||||
<ItemHeader className="items-start">
|
||||
<div className="flex flex-col gap-1">
|
||||
<ItemTitle>{t("Bucket CORS")}</ItemTitle>
|
||||
<ItemDescription className="text-xs text-muted-foreground">
|
||||
{corsRules.length > 0 ? t("Configured") : t("Not Enabled")}
|
||||
</ItemDescription>
|
||||
</div>
|
||||
<ItemActions>
|
||||
{canEditCors ? (
|
||||
<Button variant="outline" size="sm" className="shrink-0" onClick={openCorsModal}>
|
||||
<RiEdit2Line className="me-2 size-4" />
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
) : null}
|
||||
</ItemActions>
|
||||
</ItemHeader>
|
||||
<ItemContent>
|
||||
{corsRules.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("CORS Rules")}</p>
|
||||
<p className="text-sm text-foreground">{corsRules.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t("Allowed Methods")}</p>
|
||||
<p className="text-sm text-foreground">{corsMethods.join(", ") || "-"}</p>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<p className="text-xs text-muted-foreground">{t("Allowed Origins")}</p>
|
||||
<p className="break-all text-sm text-foreground">{corsOrigins.join(", ") || "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ItemDescription className="text-sm">{t("No Data")}</ItemDescription>
|
||||
)}
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
||||
{/* Tag */}
|
||||
<Item variant="outline" className="flex-col items-stretch gap-4">
|
||||
<ItemHeader className="items-center">
|
||||
@@ -750,6 +843,57 @@ export function BucketInfo({ bucketName }: BucketInfoProps) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* CORS Modal */}
|
||||
<Dialog open={showCorsModal} onOpenChange={setShowCorsModal}>
|
||||
<DialogContent className="sm:max-w-2xl" showCloseButton>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("Set Bucket CORS")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Field className="flex items-center justify-between">
|
||||
<FieldLabel>{t("Bucket CORS")}</FieldLabel>
|
||||
<FieldContent className="flex justify-end">
|
||||
<Switch checked={corsFormEnabled} onCheckedChange={setCorsFormEnabled} />
|
||||
</FieldContent>
|
||||
</Field>
|
||||
{corsFormEnabled ? (
|
||||
<Field>
|
||||
<FieldLabel>{t("CORS Configuration")}</FieldLabel>
|
||||
<FieldContent>
|
||||
<div className="max-h-[60vh] overflow-y-auto rounded-md border p-2">
|
||||
<Textarea
|
||||
value={corsFormContent}
|
||||
onChange={(e) => setCorsFormContent(e.target.value)}
|
||||
className="min-h-[260px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</FieldContent>
|
||||
<FieldDescription>
|
||||
{t('CORS JSON must be an array of rules or an object with a "CORSRules" array.')}
|
||||
</FieldDescription>
|
||||
{corsValidation.error ? (
|
||||
<FieldDescription className="text-destructive">{corsValidation.error}</FieldDescription>
|
||||
) : (
|
||||
<FieldDescription>{t("CORS JSON validation passed")}</FieldDescription>
|
||||
)}
|
||||
</Field>
|
||||
) : (
|
||||
<FieldDescription>
|
||||
{t("Disable Bucket CORS to remove the current cross-origin configuration.")}
|
||||
</FieldDescription>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCorsModal(false)}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button onClick={submitCors} disabled={corsFormEnabled && Boolean(corsValidation.error)}>
|
||||
{t("Confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Tag Modal */}
|
||||
<Dialog open={showTagModal} onOpenChange={setShowTagModal}>
|
||||
<DialogContent className="sm:max-w-md" showCloseButton>
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useCallback } from "react"
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
DeleteBucketCommand,
|
||||
DeleteBucketCorsCommand,
|
||||
GetBucketEncryptionCommand,
|
||||
GetBucketCorsCommand,
|
||||
GetBucketLifecycleConfigurationCommand,
|
||||
GetBucketPolicyCommand,
|
||||
GetBucketPolicyStatusCommand,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
HeadBucketCommand,
|
||||
ListBucketsCommand,
|
||||
type ListBucketsCommandOutput,
|
||||
PutBucketCorsCommand,
|
||||
PutBucketEncryptionCommand,
|
||||
PutBucketLifecycleConfigurationCommand,
|
||||
PutBucketPolicyCommand,
|
||||
@@ -284,6 +287,32 @@ export function useBucket() {
|
||||
[client],
|
||||
)
|
||||
|
||||
const getBucketCors = useCallback(
|
||||
async (bucket: string) => {
|
||||
return client.send(new GetBucketCorsCommand({ Bucket: bucket }))
|
||||
},
|
||||
[client],
|
||||
)
|
||||
|
||||
const putBucketCors = useCallback(
|
||||
async (bucket: string, corsConfiguration: unknown) => {
|
||||
return client.send(
|
||||
new PutBucketCorsCommand({
|
||||
Bucket: bucket,
|
||||
CORSConfiguration: corsConfiguration as never,
|
||||
}),
|
||||
)
|
||||
},
|
||||
[client],
|
||||
)
|
||||
|
||||
const deleteBucketCors = useCallback(
|
||||
async (bucket: string) => {
|
||||
return client.send(new DeleteBucketCorsCommand({ Bucket: bucket }))
|
||||
},
|
||||
[client],
|
||||
)
|
||||
|
||||
const getBucketReplication = useCallback(
|
||||
async (bucket: string) => {
|
||||
return client.send(new GetBucketReplicationCommand({ Bucket: bucket }))
|
||||
@@ -376,6 +405,9 @@ export function useBucket() {
|
||||
getBucketEncryption,
|
||||
putBucketEncryption,
|
||||
deleteBucketEncryption,
|
||||
getBucketCors,
|
||||
putBucketCors,
|
||||
deleteBucketCors,
|
||||
getBucketReplication,
|
||||
putBucketReplication,
|
||||
deleteBucketReplication,
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
"Advanced Monitoring": "Advanced Monitoring",
|
||||
"Advanced Settings": "Advanced Settings",
|
||||
"Algorithm": "Algorithm",
|
||||
"Allowed Methods": "Allowed Methods",
|
||||
"Allowed Origins": "Allowed Origins",
|
||||
"Amazon Resource Name": "Amazon Resource Name",
|
||||
"Apache License": "Apache License",
|
||||
"AppRole": "AppRole",
|
||||
@@ -83,6 +85,7 @@
|
||||
"Bucket": "Bucket",
|
||||
"Bucket Configuration": "Bucket Configuration",
|
||||
"Bucket Count": "Bucket Count",
|
||||
"Bucket CORS": "Bucket CORS",
|
||||
"Bucket Encryption Management": "Bucket Encryption Management",
|
||||
"Bucket Events": "Bucket Events",
|
||||
"Bucket Notification": "Bucket Notification",
|
||||
@@ -93,6 +96,7 @@
|
||||
"Bucket Quota Insufficient": "Bucket Quota Insufficient",
|
||||
"Quota Warning Content": "The total size of the selected files is {total}, which exceeds the remaining bucket quota {remaining}. Do you want to continue?",
|
||||
"Continue Upload": "Continue Upload",
|
||||
"Set Bucket CORS": "Set Bucket CORS",
|
||||
"Set Bucket Quota": "Set Bucket Quota",
|
||||
"Bucket Replication": "Bucket Replication",
|
||||
"Bucket Setting": "Bucket Setting",
|
||||
@@ -103,6 +107,10 @@
|
||||
"Buckets": "Buckets",
|
||||
"COMMENT_KEY": "Comment",
|
||||
"COMPLIANCE": "COMPLIANCE",
|
||||
"CORS Configuration": "CORS Configuration",
|
||||
"CORS JSON must be an array of rules or an object with a \"CORSRules\" array.": "CORS JSON must be an array of rules or an object with a \"CORSRules\" array.",
|
||||
"CORS JSON validation passed": "CORS JSON validation passed",
|
||||
"CORS Rules": "CORS Rules",
|
||||
"Cache Enabled": "Cache Enabled",
|
||||
"Cache Hits": "Cache Hits",
|
||||
"Cache Misses": "Cache Misses",
|
||||
@@ -201,6 +209,7 @@
|
||||
"Delete Selected": "Delete Selected",
|
||||
"Delete Success": "Delete Success",
|
||||
"Delete Tag Confirm": "Delete Tag Confirm",
|
||||
"Disable Bucket CORS to remove the current cross-origin configuration.": "Disable Bucket CORS to remove the current cross-origin configuration.",
|
||||
"Deleting": "Deleting({count})",
|
||||
"Deleting...": "Deleting...",
|
||||
"Description": "Description",
|
||||
@@ -332,6 +341,7 @@
|
||||
"Info": "Info",
|
||||
"Infrastructure Health": "Infrastructure Health",
|
||||
"Inspect individual server health, disk utilization, and network status.": "Inspect individual server health, disk utilization, and network status.",
|
||||
"Invalid CORS configuration": "Invalid CORS configuration",
|
||||
"Invalid server address format": "Invalid server address format",
|
||||
"JSON Editor": "JSON Editor",
|
||||
"KMS Configuration": "KMS Configuration",
|
||||
@@ -471,6 +481,7 @@
|
||||
"Non-current Version": "Non-current Version",
|
||||
"Normal": "Normal",
|
||||
"Not Configured": "Not Configured",
|
||||
"Not Enabled": "Not Enabled",
|
||||
"Not configured": "Not configured",
|
||||
"Not specified": "Not specified",
|
||||
"Note: AccessKey and SecretKey values are required for each site when adding or editing peer sites": "Note: AccessKey and SecretKey values are required for each site when adding or editing peer sites",
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
"Advanced Monitoring": "高级监控",
|
||||
"Advanced Settings": "高级设置",
|
||||
"Algorithm": "算法",
|
||||
"Allowed Methods": "允许的方法",
|
||||
"Allowed Origins": "允许的来源",
|
||||
"Amazon Resource Name": "Amazon资源名称",
|
||||
"Apache License": "Apache许可证",
|
||||
"AppRole": "应用角色",
|
||||
@@ -87,6 +89,7 @@
|
||||
"Bucket": "存储桶",
|
||||
"Bucket Configuration": "桶配置",
|
||||
"Bucket Count": "存储桶数量",
|
||||
"Bucket CORS": "存储桶跨域配置",
|
||||
"Bucket Encryption Management": "存储桶加密管理",
|
||||
"Bucket Events": "存储桶事件",
|
||||
"Bucket Notification": "存储桶通知",
|
||||
@@ -94,6 +97,7 @@
|
||||
"Bucket Quota": "存储桶配额",
|
||||
"Bucket Quota Exceeded": "存储桶配额已超出",
|
||||
"Bucket Quota Insufficient": "存储桶配额不足",
|
||||
"Set Bucket CORS": "设置存储桶跨域配置",
|
||||
"Bucket Replication": "存储桶复制",
|
||||
"Bucket Setting": "存储桶设置",
|
||||
"Bucket encryption configured successfully": "存储桶加密配置成功",
|
||||
@@ -103,6 +107,10 @@
|
||||
"Buckets": "存储桶",
|
||||
"COMMENT_KEY": "注释",
|
||||
"COMPLIANCE": "合规",
|
||||
"CORS Configuration": "跨域配置",
|
||||
"CORS JSON must be an array of rules or an object with a \"CORSRules\" array.": "CORS JSON 必须是规则数组,或包含 \"CORSRules\" 数组的对象。",
|
||||
"CORS JSON validation passed": "CORS JSON 校验通过",
|
||||
"CORS Rules": "跨域规则数",
|
||||
"Cache Enabled": "已启用缓存",
|
||||
"Cache Hits": "缓存命中",
|
||||
"Cache Misses": "缓存未命中",
|
||||
@@ -204,6 +212,7 @@
|
||||
"Delete Selected": "删除选中项",
|
||||
"Delete Success": "删除成功",
|
||||
"Delete Tag Confirm": "你确定要删除这个标签吗?",
|
||||
"Disable Bucket CORS to remove the current cross-origin configuration.": "关闭存储桶跨域配置后,将移除当前的跨域设置。",
|
||||
"Deleting": "进行中({count})",
|
||||
"Deleting...": "删除中...",
|
||||
"Description": "描述",
|
||||
@@ -342,6 +351,7 @@
|
||||
"Infrastructure Health": "基础设施健康",
|
||||
"Inherited from group": "继承自分组",
|
||||
"Inspect individual server health, disk utilization, and network status.": "检查单个服务器健康、磁盘利用率和网络状态。",
|
||||
"Invalid CORS configuration": "跨域配置无效",
|
||||
"Invalid JSON format": "JSON 格式无效",
|
||||
"Invalid server address format": "无效的服务器地址格式",
|
||||
"JSON Editor": "JSON 编辑器",
|
||||
@@ -486,6 +496,7 @@
|
||||
"Non-current Version": "非当前版本",
|
||||
"Normal": "普通",
|
||||
"Not Configured": "未配置",
|
||||
"Not Enabled": "未启用",
|
||||
"Not configured": "未配置",
|
||||
"Not specified": "未指定",
|
||||
"Note: AccessKey and SecretKey values are required for each site when adding or editing peer sites": "注意:添加或编辑对等站点时,每个站点的 AccessKey 和 SecretKey 值都是必需的",
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { CORSRule } from "@aws-sdk/client-s3"
|
||||
|
||||
export interface BucketCorsConfiguration {
|
||||
CORSRules: CORSRule[]
|
||||
}
|
||||
|
||||
interface BucketCorsValidationResult {
|
||||
config: BucketCorsConfiguration | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const ALLOWED_METHODS = new Set(["GET", "PUT", "POST", "DELETE", "HEAD"])
|
||||
const RULE_KEYS = new Set([
|
||||
"ID",
|
||||
"AllowedHeaders",
|
||||
"AllowedMethods",
|
||||
"AllowedOrigins",
|
||||
"ExposeHeaders",
|
||||
"MaxAgeSeconds",
|
||||
])
|
||||
|
||||
export const DEFAULT_BUCKET_CORS_CONFIGURATION: BucketCorsConfiguration = {
|
||||
CORSRules: [
|
||||
{
|
||||
AllowedOrigins: ["*"],
|
||||
AllowedMethods: ["GET", "PUT", "POST", "DELETE", "HEAD"],
|
||||
AllowedHeaders: ["*"],
|
||||
ExposeHeaders: ["ETag"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function parseStringArray(
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
options: { required?: boolean } = {},
|
||||
): string[] | undefined {
|
||||
if (value == null) {
|
||||
if (options.required) {
|
||||
throw new Error(`${fieldName} is required`)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`${fieldName} must be an array of strings`)
|
||||
}
|
||||
|
||||
const normalized = value.map((item, index) => {
|
||||
if (typeof item !== "string") {
|
||||
throw new Error(`${fieldName}[${index}] must be a string`)
|
||||
}
|
||||
|
||||
const trimmed = item.trim()
|
||||
if (!trimmed) {
|
||||
throw new Error(`${fieldName}[${index}] cannot be empty`)
|
||||
}
|
||||
|
||||
return trimmed
|
||||
})
|
||||
|
||||
if (options.required && normalized.length === 0) {
|
||||
throw new Error(`${fieldName} must contain at least one value`)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeRule(rule: unknown, index: number): CORSRule {
|
||||
if (!isRecord(rule)) {
|
||||
throw new Error(`CORSRules[${index}] must be an object`)
|
||||
}
|
||||
|
||||
for (const key of Object.keys(rule)) {
|
||||
if (!RULE_KEYS.has(key)) {
|
||||
throw new Error(`CORSRules[${index}] contains unsupported field "${key}"`)
|
||||
}
|
||||
}
|
||||
|
||||
const allowedOrigins =
|
||||
parseStringArray(rule.AllowedOrigins, `CORSRules[${index}].AllowedOrigins`, { required: true }) ?? []
|
||||
const allowedMethods =
|
||||
parseStringArray(rule.AllowedMethods, `CORSRules[${index}].AllowedMethods`, { required: true }) ?? []
|
||||
|
||||
for (const method of allowedMethods) {
|
||||
if (!ALLOWED_METHODS.has(method)) {
|
||||
throw new Error(
|
||||
`CORSRules[${index}].AllowedMethods contains invalid method "${method}". Allowed values: GET, PUT, POST, DELETE, HEAD`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const allowedHeaders = parseStringArray(rule.AllowedHeaders, `CORSRules[${index}].AllowedHeaders`)
|
||||
const exposeHeaders = parseStringArray(rule.ExposeHeaders, `CORSRules[${index}].ExposeHeaders`)
|
||||
|
||||
if (rule.ID != null && typeof rule.ID !== "string") {
|
||||
throw new Error(`CORSRules[${index}].ID must be a string`)
|
||||
}
|
||||
|
||||
const id = typeof rule.ID === "string" ? rule.ID.trim() : undefined
|
||||
if (id && id.length > 255) {
|
||||
throw new Error(`CORSRules[${index}].ID cannot be longer than 255 characters`)
|
||||
}
|
||||
|
||||
if (rule.MaxAgeSeconds != null) {
|
||||
if (typeof rule.MaxAgeSeconds !== "number" || !Number.isInteger(rule.MaxAgeSeconds) || rule.MaxAgeSeconds < 0) {
|
||||
throw new Error(`CORSRules[${index}].MaxAgeSeconds must be a non-negative integer`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...(id ? { ID: id } : {}),
|
||||
...(allowedHeaders ? { AllowedHeaders: allowedHeaders } : {}),
|
||||
AllowedMethods: allowedMethods,
|
||||
AllowedOrigins: allowedOrigins,
|
||||
...(exposeHeaders ? { ExposeHeaders: exposeHeaders } : {}),
|
||||
...(rule.MaxAgeSeconds != null ? { MaxAgeSeconds: rule.MaxAgeSeconds } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeBucketCorsConfig(value: unknown): BucketCorsConfiguration {
|
||||
const rawRules = Array.isArray(value) ? value : isRecord(value) ? value.CORSRules : undefined
|
||||
|
||||
if (!rawRules) {
|
||||
throw new Error('CORS JSON must be an array of rules or an object with a "CORSRules" array')
|
||||
}
|
||||
|
||||
if (!Array.isArray(rawRules)) {
|
||||
throw new Error("CORSRules must be an array")
|
||||
}
|
||||
|
||||
if (rawRules.length === 0) {
|
||||
throw new Error("CORSRules must contain at least one rule")
|
||||
}
|
||||
|
||||
return {
|
||||
CORSRules: rawRules.map((rule, index) => normalizeRule(rule, index)),
|
||||
}
|
||||
}
|
||||
|
||||
export function validateBucketCorsJson(content: string): BucketCorsValidationResult {
|
||||
if (!content.trim()) {
|
||||
return {
|
||||
config: null,
|
||||
error: "CORS JSON cannot be empty",
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content) as unknown
|
||||
const config = normalizeBucketCorsConfig(parsed)
|
||||
return {
|
||||
config,
|
||||
error: null,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
config: null,
|
||||
error: error instanceof Error ? error.message : "Invalid CORS JSON",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function stringifyBucketCorsConfig(config?: BucketCorsConfiguration | null): string {
|
||||
return JSON.stringify(config?.CORSRules ?? DEFAULT_BUCKET_CORS_CONFIGURATION.CORSRules, null, 2)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export type ConsoleCapability =
|
||||
| "bucket.policy.delete"
|
||||
| "bucket.policy.edit"
|
||||
| "bucket.encryption.edit"
|
||||
| "bucket.cors.edit"
|
||||
| "bucket.tag.edit"
|
||||
| "bucket.versioning.edit"
|
||||
| "bucket.objectLock.edit"
|
||||
@@ -63,6 +64,7 @@ const CAPABILITY_REQUIREMENTS: Record<ConsoleCapability, CapabilityRequirement[]
|
||||
"bucket.policy.delete": [{ actions: ["s3:DeleteBucketPolicy"], resource: "bucket" }],
|
||||
"bucket.policy.edit": [{ actions: ["s3:PutBucketPolicy", "s3:DeleteBucketPolicy"], mode: "any", resource: "bucket" }],
|
||||
"bucket.encryption.edit": [{ actions: ["s3:PutBucketEncryption"], resource: "bucket" }],
|
||||
"bucket.cors.edit": [{ actions: ["s3:PutBucketCORS"], resource: "bucket" }],
|
||||
"bucket.tag.edit": [{ actions: ["s3:PutBucketTagging"], resource: "bucket" }],
|
||||
"bucket.versioning.edit": [{ actions: ["s3:PutBucketVersioning"], resource: "bucket" }],
|
||||
"bucket.objectLock.edit": [{ actions: ["s3:PutBucketObjectLockConfiguration"], resource: "bucket" }],
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import test from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { normalizeBucketCorsConfig, stringifyBucketCorsConfig, validateBucketCorsJson } from "../../lib/bucket-cors"
|
||||
|
||||
test("normalizeBucketCorsConfig accepts an object with CORSRules", () => {
|
||||
const config = normalizeBucketCorsConfig({
|
||||
CORSRules: [
|
||||
{
|
||||
AllowedOrigins: ["https://example.com"],
|
||||
AllowedMethods: ["GET", "PUT"],
|
||||
MaxAgeSeconds: 3600,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
assert.deepEqual(config, {
|
||||
CORSRules: [
|
||||
{
|
||||
AllowedOrigins: ["https://example.com"],
|
||||
AllowedMethods: ["GET", "PUT"],
|
||||
MaxAgeSeconds: 3600,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("validateBucketCorsJson accepts a plain array of rules", () => {
|
||||
const result = validateBucketCorsJson(
|
||||
JSON.stringify([
|
||||
{
|
||||
AllowedOrigins: ["*"],
|
||||
AllowedMethods: ["GET"],
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
assert.equal(result.error, null)
|
||||
assert.deepEqual(result.config, {
|
||||
CORSRules: [
|
||||
{
|
||||
AllowedOrigins: ["*"],
|
||||
AllowedMethods: ["GET"],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("validateBucketCorsJson rejects invalid methods", () => {
|
||||
const result = validateBucketCorsJson(
|
||||
JSON.stringify({
|
||||
CORSRules: [
|
||||
{
|
||||
AllowedOrigins: ["https://example.com"],
|
||||
AllowedMethods: ["PATCH"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
assert.equal(result.config, null)
|
||||
assert.match(result.error ?? "", /invalid method "PATCH"/)
|
||||
})
|
||||
|
||||
test("stringifyBucketCorsConfig returns formatted json", () => {
|
||||
const text = stringifyBucketCorsConfig({
|
||||
CORSRules: [
|
||||
{
|
||||
AllowedOrigins: ["https://example.com"],
|
||||
AllowedMethods: ["GET"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
assert.match(text, /"CORSRules"/)
|
||||
assert.match(text, /"AllowedOrigins"/)
|
||||
})
|
||||
Reference in New Issue
Block a user