diff --git a/.changeset/thin-hairs-explain.md b/.changeset/thin-hairs-explain.md new file mode 100644 index 000000000000..8a76fed555b4 --- /dev/null +++ b/.changeset/thin-hairs-explain.md @@ -0,0 +1,5 @@ +--- +"wrangler": minor +--- + +Added the ability to enable, disable, and get r2.dev public access URLs for R2 buckets. diff --git a/packages/wrangler/src/__tests__/r2.test.ts b/packages/wrangler/src/__tests__/r2.test.ts index b0dd139c2716..b3bc1ebea6a4 100644 --- a/packages/wrangler/src/__tests__/r2.test.ts +++ b/packages/wrangler/src/__tests__/r2.test.ts @@ -99,6 +99,7 @@ describe("r2", () => { wrangler r2 bucket sippy Manage Sippy incremental migration on an R2 bucket wrangler r2 bucket notification Manage event notification rules for an R2 bucket 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 GLOBAL FLAGS -j, --experimental-json-config Experimental: support wrangler.json [boolean] @@ -134,6 +135,7 @@ describe("r2", () => { wrangler r2 bucket sippy Manage Sippy incremental migration on an R2 bucket wrangler r2 bucket notification Manage event notification rules for an R2 bucket 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 GLOBAL FLAGS -j, --experimental-json-config Experimental: support wrangler.json [boolean] @@ -1510,9 +1512,9 @@ describe("r2", () => { `r2 bucket domain add ${bucketName} --domain ${domainName} --zone-id ${zoneId}` ); expect(std.out).toMatchInlineSnapshot(` - "Connecting custom domain 'example.com' to bucket 'my-bucket'... - ✨ Custom domain 'example.com' connected successfully." - `); + "Connecting custom domain 'example.com' to bucket 'my-bucket'... + ✨ Custom domain 'example.com' connected successfully." + `); }); it("should error if domain and zone-id are not provided", async () => { @@ -1523,10 +1525,10 @@ describe("r2", () => { `[Error: Missing required arguments: domain, zone-id]` ); expect(std.err).toMatchInlineSnapshot(` - "X [ERROR] Missing required arguments: domain, zone-id + "X [ERROR] Missing required arguments: domain, zone-id - " - `); + " + `); }); }); describe("list", () => { @@ -1574,23 +1576,23 @@ describe("r2", () => { ); await runWrangler(`r2 bucket domain list ${bucketName}`); expect(std.out).toMatchInlineSnapshot(` - "Listing custom domains connected to bucket 'my-bucket'... - domain: example.com - enabled: Yes - ownership_status: verified - ssl_status: active - min_tls_version: 1.2 - zone_id: zone-id-123 - zone_name: example-zone - - domain: test.com - enabled: No - ownership_status: pending - ssl_status: pending - min_tls_version: 1.0 - zone_id: zone-id-456 - zone_name: test-zone" - `); + "Listing custom domains connected to bucket 'my-bucket'... + domain: example.com + enabled: Yes + ownership_status: verified + ssl_status: active + min_tls_version: 1.2 + zone_id: zone-id-123 + zone_name: example-zone + + domain: test.com + enabled: No + ownership_status: pending + ssl_status: pending + min_tls_version: 1.0 + zone_id: zone-id-456 + zone_name: test-zone" + `); }); }); describe("remove", () => { @@ -1625,9 +1627,9 @@ describe("r2", () => { `r2 bucket domain remove ${bucketName} --domain ${domainName}` ); expect(std.out).toMatchInlineSnapshot(` - "Removing custom domain 'example.com' from bucket 'my-bucket'... - Custom domain 'example.com' removed successfully." - `); + "Removing custom domain 'example.com' from bucket 'my-bucket'... + Custom domain 'example.com' removed successfully." + `); }); }); describe("update", () => { @@ -1660,9 +1662,141 @@ describe("r2", () => { `r2 bucket domain update ${bucketName} --domain ${domainName} --min-tls 1.3` ); expect(std.out).toMatchInlineSnapshot(` - "Updating custom domain 'example.com' for bucket 'my-bucket'... - ✨ Custom domain 'example.com' updated successfully." - `); + "Updating custom domain 'example.com' for bucket 'my-bucket'... + ✨ Custom domain 'example.com' updated successfully." + `); + }); + }); + }); + describe("dev-url", () => { + const { setIsTTY } = useMockIsTTY(); + mockAccountId(); + mockApiToken(); + describe("get", () => { + it("should retrieve the r2.dev URL of a bucket when public access is enabled", async () => { + const bucketName = "my-bucket"; + const domainInfo = { + bucketId: "bucket-id-123", + domain: "pub-bucket-id-123.r2.dev", + enabled: true, + }; + msw.use( + http.get( + "*/accounts/:accountId/r2/buckets/:bucketName/domains/managed", + async ({ params }) => { + const { accountId, bucketName: bucketParam } = params; + expect(accountId).toEqual("some-account-id"); + expect(bucketParam).toEqual(bucketName); + return HttpResponse.json(createFetchResult({ ...domainInfo })); + }, + { once: true } + ) + ); + await runWrangler(`r2 bucket dev-url get ${bucketName}`); + expect(std.out).toMatchInlineSnapshot(` + "Public access is enabled at 'https://pub-bucket-id-123.r2.dev'." + `); + }); + + it("should show that public access is disabled when it is disabled", async () => { + const bucketName = "my-bucket"; + const domainInfo = { + bucketId: "bucket-id-123", + domain: "pub-bucket-id-123.r2.dev", + enabled: false, + }; + msw.use( + http.get( + "*/accounts/:accountId/r2/buckets/:bucketName/domains/managed", + async ({ params }) => { + const { accountId, bucketName: bucketParam } = params; + expect(accountId).toEqual("some-account-id"); + expect(bucketParam).toEqual(bucketName); + return HttpResponse.json(createFetchResult({ ...domainInfo })); + }, + { once: true } + ) + ); + await runWrangler(`r2 bucket dev-url get ${bucketName}`); + expect(std.out).toMatchInlineSnapshot(` + "Public access via the r2.dev URL is disabled." + `); + }); + }); + + describe("enable", () => { + it("should enable public access", async () => { + const bucketName = "my-bucket"; + const domainInfo = { + bucketId: "bucket-id-123", + domain: "pub-bucket-id-123.r2.dev", + enabled: true, + }; + + setIsTTY(true); + mockConfirm({ + text: + `Are you sure you enable public access for bucket '${bucketName}'? ` + + `The contents of your bucket will be made publicly available at its r2.dev URL`, + result: true, + }); + msw.use( + http.put( + "*/accounts/:accountId/r2/buckets/:bucketName/domains/managed", + async ({ request, params }) => { + const { accountId, bucketName: bucketParam } = params; + expect(accountId).toEqual("some-account-id"); + expect(bucketParam).toEqual(bucketName); + const requestBody = await request.json(); + expect(requestBody).toEqual({ enabled: true }); + return HttpResponse.json(createFetchResult({ ...domainInfo })); + }, + { once: true } + ) + ); + await runWrangler(`r2 bucket dev-url enable ${bucketName}`); + expect(std.out).toMatchInlineSnapshot(` + "Enabling public access for bucket 'my-bucket'... + ✨ Public access enabled at 'https://pub-bucket-id-123.r2.dev'." + `); + }); + }); + + describe("disable", () => { + it("should disable public access", async () => { + const bucketName = "my-bucket"; + const domainInfo = { + bucketId: "bucket-id-123", + domain: "pub-bucket-id-123.r2.dev", + enabled: false, + }; + + setIsTTY(true); + mockConfirm({ + text: + `Are you sure you disable public access for bucket '${bucketName}'? ` + + `The contents of your bucket will no longer be publicly available at its r2.dev URL`, + result: true, + }); + msw.use( + http.put( + "*/accounts/:accountId/r2/buckets/:bucketName/domains/managed", + async ({ request, params }) => { + const { accountId, bucketName: bucketParam } = params; + expect(accountId).toEqual("some-account-id"); + expect(bucketParam).toEqual(bucketName); + const requestBody = await request.json(); + expect(requestBody).toEqual({ enabled: false }); + return HttpResponse.json(createFetchResult({ ...domainInfo })); + }, + { once: true } + ) + ); + await runWrangler(`r2 bucket dev-url disable ${bucketName}`); + expect(std.out).toMatchInlineSnapshot(` + "Disabling public access for bucket 'my-bucket'... + Public access disabled at 'https://pub-bucket-id-123.r2.dev'." + `); }); }); }); diff --git a/packages/wrangler/src/r2/helpers.ts b/packages/wrangler/src/r2/helpers.ts index ac0b31960346..6e0993efac9e 100644 --- a/packages/wrangler/src/r2/helpers.ts +++ b/packages/wrangler/src/r2/helpers.ts @@ -742,6 +742,56 @@ export async function configureCustomDomainSettings( ); } +export interface R2DevDomainInfo { + bucketId: string; + domain: string; + enabled: boolean; +} + +export async function getR2DevDomain( + accountId: string, + bucketName: string, + jurisdiction?: string +): Promise { + const headers: HeadersInit = {}; + if (jurisdiction) { + headers["cf-r2-jurisdiction"] = jurisdiction; + } + + const result = await fetchResult( + `/accounts/${accountId}/r2/buckets/${bucketName}/domains/managed`, + { + method: "GET", + headers, + } + ); + return result; +} + +export async function updateR2DevDomain( + accountId: string, + bucketName: string, + enabled: boolean, + jurisdiction?: string +): Promise { + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + if (jurisdiction) { + headers["cf-r2-jurisdiction"] = jurisdiction; + } + + const result = await fetchResult( + `/accounts/${accountId}/r2/buckets/${bucketName}/domains/managed`, + { + method: "PUT", + headers, + body: JSON.stringify({ enabled }), + } + ); + return result; +} + /** * R2 bucket names must only contain alphanumeric and - characters. */ diff --git a/packages/wrangler/src/r2/index.ts b/packages/wrangler/src/r2/index.ts index 525464007e51..a37ec0c64786 100644 --- a/packages/wrangler/src/r2/index.ts +++ b/packages/wrangler/src/r2/index.ts @@ -24,6 +24,7 @@ import { usingLocalBucket, } from "./helpers"; import * as Notification from "./notification"; +import * as PublicDevUrl from "./public-dev-url"; import * as Sippy from "./sippy"; import type { CommonYargsArgv, SubHelp } from "../yargs-types"; import type { R2PutOptions } from "@cloudflare/workers-types/experimental"; @@ -640,6 +641,31 @@ export function r2(r2Yargs: CommonYargsArgv, subHelp: SubHelp) { ); } ); + r2BucketYargs.command( + "dev-url", + "Manage public access via the r2.dev URL for an R2 bucket", + (devUrlYargs) => { + return devUrlYargs + .command( + "enable ", + "Enable public access via the r2.dev URL for an R2 bucket", + PublicDevUrl.EnableOptions, + PublicDevUrl.EnableHandler + ) + .command( + "disable ", + "Disable public access via the r2.dev URL for an R2 bucket", + PublicDevUrl.DisableOptions, + PublicDevUrl.DisableHandler + ) + .command( + "get ", + "Get the r2.dev URL and status for an R2 bucket", + PublicDevUrl.GetOptions, + PublicDevUrl.GetHandler + ); + } + ); return r2BucketYargs; }); } diff --git a/packages/wrangler/src/r2/public-dev-url.ts b/packages/wrangler/src/r2/public-dev-url.ts new file mode 100644 index 000000000000..d295ec6439a9 --- /dev/null +++ b/packages/wrangler/src/r2/public-dev-url.ts @@ -0,0 +1,151 @@ +import { readConfig } from "../config"; +import { confirm } from "../dialogs"; +import { logger } from "../logger"; +import { printWranglerBanner } from "../update-check"; +import { requireAuth } from "../user"; +import { getR2DevDomain, updateR2DevDomain } from "./helpers"; +import type { + CommonYargsArgv, + StrictYargsOptionsToInterface, +} from "../yargs-types"; + +export function GetOptions(yargs: CommonYargsArgv) { + return yargs + .positional("bucket", { + describe: "The name of the R2 bucket whose r2.dev URL status to retrieve", + type: "string", + demandOption: true, + }) + .option("jurisdiction", { + describe: "The jurisdiction where the bucket exists", + alias: "J", + requiresArg: true, + type: "string", + }); +} + +export async function GetHandler( + args: StrictYargsOptionsToInterface +) { + await printWranglerBanner(); + const config = readConfig(args.config, args); + const accountId = await requireAuth(config); + + const { bucket, jurisdiction } = args; + + const devDomain = await getR2DevDomain(accountId, bucket, jurisdiction); + + if (devDomain.enabled) { + logger.log(`Public access is enabled at 'https://${devDomain.domain}'.`); + } else { + logger.log(`Public access via the r2.dev URL is disabled.`); + } +} + +export function EnableOptions(yargs: CommonYargsArgv) { + return yargs + .positional("bucket", { + describe: + "The name of the R2 bucket to enable public access via its r2.dev URL", + type: "string", + demandOption: true, + }) + .option("jurisdiction", { + describe: "The jurisdiction where the bucket exists", + alias: "J", + requiresArg: true, + type: "string", + }) + .option("force", { + describe: "Skip confirmation", + type: "boolean", + alias: "y", + default: false, + }); +} + +export async function EnableHandler( + args: StrictYargsOptionsToInterface +) { + await printWranglerBanner(); + const config = readConfig(args.config, args); + const accountId = await requireAuth(config); + + const { bucket, jurisdiction, force } = args; + + if (!force) { + const confirmedAdd = await confirm( + `Are you sure you enable public access for bucket '${bucket}'? ` + + `The contents of your bucket will be made publicly available at its r2.dev URL` + ); + if (!confirmedAdd) { + logger.log("Enable cancelled."); + return; + } + } + + logger.log(`Enabling public access for bucket '${bucket}'...`); + + const devDomain = await updateR2DevDomain( + accountId, + bucket, + true, + jurisdiction + ); + + logger.log(`✨ Public access enabled at 'https://${devDomain.domain}'.`); +} + +export function DisableOptions(yargs: CommonYargsArgv) { + return yargs + .positional("bucket", { + describe: + "The name of the R2 bucket to disable public access via its r2.dev URL", + type: "string", + demandOption: true, + }) + .option("jurisdiction", { + describe: "The jurisdiction where the bucket exists", + alias: "J", + requiresArg: true, + type: "string", + }) + .option("force", { + describe: "Skip confirmation", + type: "boolean", + alias: "y", + default: false, + }); +} + +export async function DisableHandler( + args: StrictYargsOptionsToInterface +) { + await printWranglerBanner(); + const config = readConfig(args.config, args); + const accountId = await requireAuth(config); + + const { bucket, jurisdiction, force } = args; + + if (!force) { + const confirmedAdd = await confirm( + `Are you sure you disable public access for bucket '${bucket}'? ` + + `The contents of your bucket will no longer be publicly available at its r2.dev URL` + ); + if (!confirmedAdd) { + logger.log("Disable cancelled."); + return; + } + } + + logger.log(`Disabling public access for bucket '${bucket}'...`); + + const devDomain = await updateR2DevDomain( + accountId, + bucket, + false, + jurisdiction + ); + + logger.log(`Public access disabled at 'https://${devDomain.domain}'.`); +}