diff --git a/.changeset/five-dryers-swim.md b/.changeset/five-dryers-swim.md new file mode 100644 index 000000000000..3cd301d0b9ff --- /dev/null +++ b/.changeset/five-dryers-swim.md @@ -0,0 +1,5 @@ +--- +"wrangler": minor +--- + +Added r2 bucket cors command to Wrangler including list, set, delete diff --git a/packages/wrangler/src/__tests__/r2.test.ts b/packages/wrangler/src/__tests__/r2.test.ts index afbd9bccbb42..370a32e2d5f4 100644 --- a/packages/wrangler/src/__tests__/r2.test.ts +++ b/packages/wrangler/src/__tests__/r2.test.ts @@ -95,6 +95,7 @@ describe("r2", () => { wrangler r2 bucket domain Manage custom domains for an R2 bucket wrangler r2 bucket dev-url Manage public access via the r2.dev URL for an R2 bucket wrangler r2 bucket lifecycle Manage lifecycle rules for an R2 bucket + wrangler r2 bucket cors Manage CORS configuration for an R2 bucket GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -132,6 +133,7 @@ describe("r2", () => { wrangler r2 bucket domain Manage custom domains for an R2 bucket wrangler r2 bucket dev-url Manage public access via the r2.dev URL for an R2 bucket wrangler r2 bucket lifecycle Manage lifecycle rules for an R2 bucket + wrangler r2 bucket cors Manage CORS configuration for an R2 bucket GLOBAL FLAGS -c, --config Path to Wrangler configuration file [string] @@ -2055,6 +2057,155 @@ describe("r2", () => { }); }); }); + describe("cors", () => { + const { setIsTTY } = useMockIsTTY(); + mockAccountId(); + mockApiToken(); + describe("list", () => { + it("should list CORS rules when they exist", async () => { + const bucketName = "my-bucket"; + const corsRules = [ + { + allowed: { + origins: ["https://www.example.com"], + methods: ["GET", "PUT"], + headers: ["Content-Type", "Authorization"], + }, + exposeHeaders: ["ETag", "Content-Length"], + maxAgeSeconds: 8640, + }, + ]; + + msw.use( + http.get( + "*/accounts/:accountId/r2/buckets/:bucketName/cors", + async ({ params }) => { + const { accountId, bucketName: bucketParam } = params; + expect(accountId).toEqual("some-account-id"); + expect(bucketParam).toEqual(bucketName); + return HttpResponse.json( + createFetchResult({ + rules: corsRules, + }) + ); + }, + { once: true } + ) + ); + await runWrangler(`r2 bucket cors list ${bucketName}`); + expect(std.out).toMatchInlineSnapshot(` + "Listing CORS rules for bucket 'my-bucket'... + allowed_origins: https://www.example.com + allowed_methods: GET, PUT + allowed_headers: Content-Type, Authorization + exposed_headers: ETag, Content-Length + max_age_seconds: 8640" + `); + }); + }); + describe("set", () => { + it("should set CORS configuration from a JSON file", async () => { + const bucketName = "my-bucket"; + const filePath = "cors-configuration.json"; + const corsRules = { + rules: [ + { + allowed: { + origins: ["https://www.example.com"], + methods: ["GET", "PUT"], + headers: ["Content-Type", "Authorization"], + }, + exposeHeaders: ["ETag", "Content-Length"], + maxAgeSeconds: 8640, + }, + ], + }; + + writeFileSync(filePath, JSON.stringify(corsRules)); + + setIsTTY(true); + mockConfirm({ + text: `Are you sure you want to overwrite the existing CORS configuration for bucket '${bucketName}'?`, + result: true, + }); + + msw.use( + http.put( + "*/accounts/:accountId/r2/buckets/:bucketName/cors", + async ({ request, params }) => { + const { accountId, bucketName: bucketParam } = params; + expect(accountId).toEqual("some-account-id"); + expect(bucketName).toEqual(bucketParam); + const requestBody = await request.json(); + expect(requestBody).toEqual({ + ...corsRules, + }); + return HttpResponse.json(createFetchResult({})); + }, + { once: true } + ) + ); + + await runWrangler( + `r2 bucket cors set ${bucketName} --file ${filePath}` + ); + expect(std.out).toMatchInlineSnapshot(` + "Setting CORS configuration (1 rules) for bucket 'my-bucket'... + ✨ Set CORS configuration for bucket 'my-bucket'." + `); + }); + }); + describe("delete", () => { + it("should delete CORS configuration as expected", async () => { + const bucketName = "my-bucket"; + const corsRules = { + rules: [ + { + allowed: { + origins: ["https://www.example.com"], + methods: ["GET", "PUT"], + headers: ["Content-Type", "Authorization"], + }, + exposeHeaders: ["ETag", "Content-Length"], + maxAgeSeconds: 8640, + }, + ], + }; + setIsTTY(true); + mockConfirm({ + text: `Are you sure you want to clear the existing CORS configuration for bucket '${bucketName}'?`, + result: true, + }); + msw.use( + http.get( + "*/accounts/:accountId/r2/buckets/:bucketName/cors", + async ({ params }) => { + const { accountId, bucketName: bucketParam } = params; + expect(accountId).toEqual("some-account-id"); + expect(bucketParam).toEqual(bucketName); + return HttpResponse.json(createFetchResult(corsRules)); + }, + { once: true } + ), + http.delete( + "*/accounts/:accountId/r2/buckets/:bucketName/cors", + async ({ params }) => { + const { accountId, bucketName: bucketParam } = params; + expect(accountId).toEqual("some-account-id"); + expect(bucketName).toEqual(bucketParam); + return HttpResponse.json(createFetchResult({})); + }, + { once: true } + ) + ); + await runWrangler(`r2 bucket cors delete ${bucketName}`); + expect(std.out).toMatchInlineSnapshot(` + "Deleting the CORS configuration for bucket 'my-bucket'... + CORS configuration deleted for bucket 'my-bucket'." + `); + }); + }); + }); }); describe("r2 object", () => { diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index c35c6965d1ff..ec7ebfeca01e 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -99,6 +99,12 @@ import { r2BucketUpdateNamespace, r2BucketUpdateStorageClassCommand, } from "./r2/bucket"; +import { + r2BucketCORSDeleteCommand, + r2BucketCORSListCommand, + r2BucketCORSNamespace, + r2BucketCORSSetCommand, +} from "./r2/cors"; import { r2BucketDomainAddCommand, r2BucketDomainListCommand, @@ -829,6 +835,22 @@ export function createCLIParser(argv: string[]) { command: "wrangler r2 bucket lifecycle set", definition: r2BucketLifecycleSetCommand, }, + { + command: "wrangler r2 bucket cors", + definition: r2BucketCORSNamespace, + }, + { + command: "wrangler r2 bucket cors delete", + definition: r2BucketCORSDeleteCommand, + }, + { + command: "wrangler r2 bucket cors list", + definition: r2BucketCORSListCommand, + }, + { + command: "wrangler r2 bucket cors set", + definition: r2BucketCORSSetCommand, + }, ]); registry.registerNamespace("r2"); diff --git a/packages/wrangler/src/r2/cors.ts b/packages/wrangler/src/r2/cors.ts new file mode 100644 index 000000000000..d68132690e18 --- /dev/null +++ b/packages/wrangler/src/r2/cors.ts @@ -0,0 +1,173 @@ +import path from "node:path"; +import { createCommand, createNamespace } from "../core/create-command"; +import { confirm } from "../dialogs"; +import { UserError } from "../errors"; +import { logger } from "../logger"; +import { parseJSON, readFileSync } from "../parse"; +import { requireAuth } from "../user"; +import formatLabelledValues from "../utils/render-labelled-values"; +import { + deleteCORSPolicy, + getCORSPolicy, + putCORSPolicy, + tableFromCORSPolicyResponse, +} from "./helpers"; +import type { CORSRule } from "./helpers"; + +export const r2BucketCORSNamespace = createNamespace({ + metadata: { + description: "Manage CORS configuration for an R2 bucket", + status: "stable", + owner: "Product: R2", + }, +}); + +export const r2BucketCORSListCommand = createCommand({ + metadata: { + description: "List the CORS rules for an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { + describe: "The name of the R2 bucket to list the CORS rules for", + type: "string", + demandOption: true, + }, + jurisdiction: { + describe: "The jurisdiction where the bucket exists", + alias: "J", + requiresArg: true, + type: "string", + }, + }, + async handler({ bucket, jurisdiction }, { config }) { + const accountId = await requireAuth(config); + + logger.log(`Listing CORS rules for bucket '${bucket}'...`); + const corsPolicy = await getCORSPolicy(accountId, bucket, jurisdiction); + + if (corsPolicy.length === 0) { + logger.log( + `There is no CORS configuration defined for bucket '${bucket}'.` + ); + } else { + const tableOutput = tableFromCORSPolicyResponse(corsPolicy); + logger.log(tableOutput.map((x) => formatLabelledValues(x)).join("\n\n")); + } + }, +}); + +export const r2BucketCORSSetCommand = createCommand({ + metadata: { + description: "Set the CORS configuration for an R2 bucket from a JSON file", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { + describe: "The name of the R2 bucket to set the CORS configuration for", + type: "string", + demandOption: true, + }, + file: { + describe: "Path to the JSON file containing the CORS configuration", + type: "string", + demandOption: true, + requiresArg: true, + }, + jurisdiction: { + describe: "The jurisdiction where the bucket exists", + alias: "J", + requiresArg: true, + type: "string", + }, + force: { + describe: "Skip confirmation", + type: "boolean", + alias: "y", + default: false, + }, + }, + async handler({ bucket, file, jurisdiction, force }, { config }) { + const accountId = await requireAuth(config); + + const jsonFilePath = path.resolve(file); + + const corsConfig = parseJSON<{ rules: CORSRule[] }>( + readFileSync(jsonFilePath), + jsonFilePath + ); + + if (!corsConfig.rules || !Array.isArray(corsConfig.rules)) { + throw new UserError( + `The CORS configuration file must contain a 'rules' array as expected by the request body of the CORS API: ` + + `https://developers.cloudflare.com/api/operations/r2-put-bucket-cors-policy` + ); + } + + if (!force) { + const confirmedRemoval = await confirm( + `Are you sure you want to overwrite the existing CORS configuration for bucket '${bucket}'?` + ); + if (!confirmedRemoval) { + logger.log("Set cancelled."); + return; + } + } + + logger.log( + `Setting CORS configuration (${corsConfig.rules.length} rules) for bucket '${bucket}'...` + ); + await putCORSPolicy(accountId, bucket, corsConfig.rules, jurisdiction); + logger.log(`✨ Set CORS configuration for bucket '${bucket}'.`); + }, +}); + +export const r2BucketCORSDeleteCommand = createCommand({ + metadata: { + description: "Clear the CORS configuration for an R2 bucket", + status: "stable", + owner: "Product: R2", + }, + positionalArgs: ["bucket"], + args: { + bucket: { + describe: + "The name of the R2 bucket to delete the CORS configuration for", + type: "string", + demandOption: true, + }, + jurisdiction: { + describe: "The jurisdiction where the bucket exists", + alias: "J", + requiresArg: true, + type: "string", + }, + force: { + describe: "Skip confirmation", + type: "boolean", + alias: "y", + default: false, + }, + }, + async handler({ bucket, jurisdiction, force }, { config }) { + const accountId = await requireAuth(config); + + if (!force) { + const confirmedRemoval = await confirm( + `Are you sure you want to clear the existing CORS configuration for bucket '${bucket}'?` + ); + if (!confirmedRemoval) { + logger.log("Set cancelled."); + return; + } + } + + logger.log(`Deleting the CORS configuration for bucket '${bucket}'...`); + await deleteCORSPolicy(accountId, bucket, jurisdiction); + logger.log(`CORS configuration deleted for bucket '${bucket}'.`); + }, +}); diff --git a/packages/wrangler/src/r2/helpers.ts b/packages/wrangler/src/r2/helpers.ts index e8ac45cb7476..5bf83f0a9b0b 100644 --- a/packages/wrangler/src/r2/helpers.ts +++ b/packages/wrangler/src/r2/helpers.ts @@ -1084,6 +1084,92 @@ export function isNonNegativeNumber(str: string): boolean { return num >= 0; } +export interface CORSRule { + allowed?: { + origins?: string[]; + methods?: string[]; + headers?: string[]; + }; + exposeHeaders?: string[]; + maxAgeSeconds?: number; +} + +export async function getCORSPolicy( + accountId: string, + bucketName: string, + jurisdiction?: string +): Promise { + const headers: HeadersInit = {}; + if (jurisdiction) { + headers["cf-r2-jurisdiction"] = jurisdiction; + } + + const result = await fetchResult<{ rules: CORSRule[] }>( + `/accounts/${accountId}/r2/buckets/${bucketName}/cors`, + { + method: "GET", + headers, + } + ); + return result.rules; +} + +export function tableFromCORSPolicyResponse(rules: CORSRule[]): { + allowed_origins: string; + allowed_methods: string; + allowed_headers: string; + exposed_headers: string; + max_age_seconds: string; +}[] { + const rows = []; + for (const rule of rules) { + rows.push({ + allowed_origins: rule.allowed?.origins?.join(", ") || "(no origins)", + allowed_methods: rule.allowed?.methods?.join(", ") || "(no methods)", + allowed_headers: rule.allowed?.headers?.join(", ") || "(no headers)", + exposed_headers: rule.exposeHeaders?.join(", ") || "(no exposed headers)", + max_age_seconds: rule.maxAgeSeconds?.toString() || "(0 seconds)", + }); + } + return rows; +} + +export async function putCORSPolicy( + accountId: string, + bucketName: string, + rules: CORSRule[], + jurisdiction?: string +): Promise { + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + if (jurisdiction) { + headers["cf-r2-jurisdiction"] = jurisdiction; + } + + await fetchResult(`/accounts/${accountId}/r2/buckets/${bucketName}/cors`, { + method: "PUT", + headers, + body: JSON.stringify({ rules: rules }), + }); +} + +export async function deleteCORSPolicy( + accountId: string, + bucketName: string, + jurisdiction?: string +): Promise { + const headers: HeadersInit = {}; + if (jurisdiction) { + headers["cf-r2-jurisdiction"] = jurisdiction; + } + + await fetchResult(`/accounts/${accountId}/r2/buckets/${bucketName}/cors`, { + method: "DELETE", + headers, + }); +} + /** * R2 bucket names must only contain alphanumeric and - characters. */