From 50aea4b226211b1a645c3bf5b3c3c97ab0f228c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Wed, 5 Feb 2025 18:43:09 +0100 Subject: [PATCH 1/5] add bucket creation --- lib/cloudflare/accounts.rb | 6 ++++ lib/cloudflare/r2/buckets.rb | 64 ++++++++++++++++++++++++++++++++++++ lib/cloudflare/r2/cors.rb | 19 +++++++++++ lib/cloudflare/r2/domains.rb | 45 +++++++++++++++++++++++++ 4 files changed, 134 insertions(+) create mode 100644 lib/cloudflare/r2/buckets.rb create mode 100644 lib/cloudflare/r2/cors.rb create mode 100644 lib/cloudflare/r2/domains.rb diff --git a/lib/cloudflare/accounts.rb b/lib/cloudflare/accounts.rb index 7151d00..0334930 100644 --- a/lib/cloudflare/accounts.rb +++ b/lib/cloudflare/accounts.rb @@ -3,10 +3,12 @@ # Released under the MIT License. # Copyright, 2018-2024, by Samuel Williams. # Copyright, 2019, by Rob Widmer. +# Copyright, 2025, by Ivan Vergés. require_relative "representation" require_relative "paginate" require_relative "kv/namespaces" +require_relative "r2/buckets" module Cloudflare class Account < Representation @@ -17,6 +19,10 @@ def id def kv_namespaces self.with(KV::Namespaces, path: "storage/kv/namespaces") end + + def r2_buckets + self.with(R2::Buckets, path: "r2/buckets") + end end class Accounts < Representation diff --git a/lib/cloudflare/r2/buckets.rb b/lib/cloudflare/r2/buckets.rb new file mode 100644 index 0000000..5791338 --- /dev/null +++ b/lib/cloudflare/r2/buckets.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Ivan Vergés. + +require_relative "../paginate" +require_relative "../representation" +require_relative "domains" +require_relative "cors" + +module Cloudflare + module R2 + class Bucket < Representation + include Async::REST::Representation::Mutable + + def name + result[:name] + end + + def domains + self.with(Domains, path: "#{name}/domains/custom") + end + + def cors + self.with(Cors, path: "#{name}/cors") + end + + def create_cors(**options) + self.class.put(@resource.with(path: "#{name}/cors"), options) do |resource, response| + if response.success? + cors + else + raise RequestError.new(resource, response.read) + end + end + end + end + + class Buckets < Representation + include Paginate + + def representation + Bucket + end + + def result + value[:result][:buckets] + end + + def create(name, **options) + payload = {name: name, **options} + self.class.post(@resource, payload) do |resource, response| + value = response.read + + Bucket.new(resource, value: value, metadata: response.headers) + end + end + + def find_by_name(name) + each.find {|bucket| bucket.name == name } + end + end + end +end \ No newline at end of file diff --git a/lib/cloudflare/r2/cors.rb b/lib/cloudflare/r2/cors.rb new file mode 100644 index 0000000..160bc05 --- /dev/null +++ b/lib/cloudflare/r2/cors.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Ivan Vergés. + +require_relative "../paginate" +require_relative "../representation" + +module Cloudflare + module R2 + class Cors < Representation + include Async::REST::Representation::Mutable + + def rules + result[:rules] + end + end + end +end \ No newline at end of file diff --git a/lib/cloudflare/r2/domains.rb b/lib/cloudflare/r2/domains.rb new file mode 100644 index 0000000..d0688d9 --- /dev/null +++ b/lib/cloudflare/r2/domains.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Ivan Vergés. + +require_relative "../paginate" +require_relative "../representation" + +module Cloudflare + module R2 + class Domain < Representation + include Async::REST::Representation::Mutable + + def name + result[:domain] + end + end + + class Domains < Representation + include Paginate + + def representation + R2::Domain + end + + def result + value[:result][:domains] + end + + def attach(domain, **options) + payload = {domain:, **options} + + self.class.post(@resource, payload) do |resource, response| + value = response.read + + Domain.new(resource, value: value, metadata: response.headers) + end + end + + def find_by_name(name) + each.find {|domain| domain.name == name } + end + end + end +end \ No newline at end of file From ccaaae7204b086cc7678cd356573a16ca67aeae5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Thu, 6 Feb 2025 11:43:24 +0100 Subject: [PATCH 2/5] init specs --- lib/cloudflare/r2/cors.rb | 2 -- lib/cloudflare/r2/domains.rb | 2 -- readme.md | 2 +- test/cloudflare/accounts.rb | 8 ++++++++ 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/cloudflare/r2/cors.rb b/lib/cloudflare/r2/cors.rb index 160bc05..d9b8b64 100644 --- a/lib/cloudflare/r2/cors.rb +++ b/lib/cloudflare/r2/cors.rb @@ -9,8 +9,6 @@ module Cloudflare module R2 class Cors < Representation - include Async::REST::Representation::Mutable - def rules result[:rules] end diff --git a/lib/cloudflare/r2/domains.rb b/lib/cloudflare/r2/domains.rb index d0688d9..3a9b046 100644 --- a/lib/cloudflare/r2/domains.rb +++ b/lib/cloudflare/r2/domains.rb @@ -9,8 +9,6 @@ module Cloudflare module R2 class Domain < Representation - include Async::REST::Representation::Mutable - def name result[:domain] end diff --git a/readme.md b/readme.md index 9181b8d..19d686d 100644 --- a/readme.md +++ b/readme.md @@ -60,7 +60,7 @@ end ### Using a Bearer Token -You can read more about [bearer tokens here](https://blog.cloudflare.com/api-tokens-general-availability/). This allows you to limit priviledges. +You can read more about [bearer tokens here](https://blog.cloudflare.com/api-tokens-general-availability/). This allows you to limit privileges. ``` ruby require 'cloudflare' diff --git a/test/cloudflare/accounts.rb b/test/cloudflare/accounts.rb index d6064a1..d167e8c 100644 --- a/test/cloudflare/accounts.rb +++ b/test/cloudflare/accounts.rb @@ -36,4 +36,12 @@ expect(namespace.resource.reference.path).to be(:end_with?, "/#{account.id}/storage/kv/namespaces") end + + it "can generate a representation for the R2 bucket endpoint" do + buckets = connection.accounts.find_by_id(account.id).r2_buckets + + expect(buckets).to be_a(Cloudflare::R2::Buckets) + + expect(buckets.resource.reference.path).to be(:end_with?, "/#{account.id}/r2/buckets") + end end From 34401e84d8e6923e88d1dc41d220e4567a0fab31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Tue, 11 Feb 2025 15:41:44 +0100 Subject: [PATCH 3/5] add specs --- lib/cloudflare/r2/buckets.rb | 9 +++++- lib/cloudflare/zones.rb | 4 +++ test/cloudflare/r2/buckets.rb | 59 +++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 test/cloudflare/r2/buckets.rb diff --git a/lib/cloudflare/r2/buckets.rb b/lib/cloudflare/r2/buckets.rb index 5791338..7abd721 100644 --- a/lib/cloudflare/r2/buckets.rb +++ b/lib/cloudflare/r2/buckets.rb @@ -26,7 +26,8 @@ def cors end def create_cors(**options) - self.class.put(@resource.with(path: "#{name}/cors"), options) do |resource, response| + payload = { bucket_name: name, **options} + self.class.put(@resource.with(path: "#{name}/cors"), payload) do |resource, response| if response.success? cors else @@ -34,6 +35,12 @@ def create_cors(**options) end end end + + def delete + self.class.delete(@resource.with(path: name)) do |resource, response| + response.success? + end + end end class Buckets < Representation diff --git a/lib/cloudflare/zones.rb b/lib/cloudflare/zones.rb index 6e8f45b..d71b014 100644 --- a/lib/cloudflare/zones.rb +++ b/lib/cloudflare/zones.rb @@ -59,6 +59,10 @@ def activation_check def name result[:name] end + + def id + result[:id] + end alias to_s name end diff --git a/test/cloudflare/r2/buckets.rb b/test/cloudflare/r2/buckets.rb new file mode 100644 index 0000000..63a4940 --- /dev/null +++ b/test/cloudflare/r2/buckets.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Ivan Vergés. + +require "cloudflare/r2/buckets" +require "cloudflare/a_connection" + +describe Cloudflare::R2::Buckets do + include_context Cloudflare::AConnection + + let(:temporary_zone_name) { "#{SecureRandom.hex(8)}-testing.com" } + let(:bucket_name) { "test-bucket-#{SecureRandom.hex(4)}" } + let(:bucket) { account.r2_buckets.create(bucket_name) } + + after do + @bucket&.delete + end + + it "can create a bucket" do + expect(bucket).to be_a(Cloudflare::R2::Bucket) + expect(bucket.name).to be == bucket_name + + fetched_bucket = account.r2_buckets.find_by_name(bucket_name) + expect(fetched_bucket).to have_attributes( + name: be == bucket.name + ) + end + + it "can attach a domain to a bucket" do + temporary_zone = zones.create(temporary_zone_name, account) + payload = { zoneId: temporary_zone.id, enabled: true } + + # this is a workaround as the domain must have the DNS records set up correctly for this to work + expect do + bucket.domains.attach("subdomain.#{temporary_zone.name}", **payload) + end.to raise_exception(Cloudflare::RequestError, message: be =~ /The specified zone id is not valid/) + + temporary_zone.delete + end + + it "can create a CORS policy" do + rules = [ + { + allowed: { + methods: ["GET", "PUT"], + headers: ["*"], + origins: ["https://example.com"] + }, + exposeHeaders: [ + "Origin", + ], + maxAgeSeconds: 3600 + } + ] + cors = bucket.create_cors(account_id: account.id, rules: rules) + expect(cors).to be_a(Cloudflare::R2::Cors) + end +end \ No newline at end of file From 19421b6691205eea23d9e9773a6ae8ec66189cb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Tue, 11 Feb 2025 18:20:09 +0100 Subject: [PATCH 4/5] add readme docs --- readme.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/readme.md b/readme.md index 19d686d..ca1031d 100644 --- a/readme.md +++ b/readme.md @@ -55,6 +55,46 @@ Cloudflare.connect(key: key, email: email) do |connection| # Block an ip: rule = zone.firewall_rules.set('block', '1.2.3.4', notes: "ssh dictionary attack") + + # Get an account + connection.accounts.find_by_id(ENV["CLOUDFLARE_ACCOUNT_ID"]) do |account| + # Find a R2 bucket + bucket = account.r2_buckets.find_by_name("a-s3-compatible-bucket") + + # Create a new bucket + bucket = account.r2_buckets.create("another-s3-compatible-bucket") + + # Attaching a public domain to a bucket + payload = { + "zoneId" => zone.id, + "enabled" => true + } + bucket.domains.attach("bucket.example.com", **payload) + + # Adding a CORS policy to a bucket + rules = [ + { + allowed: { + origins: ["domain.tld", "anotherdomain.tld"], + methods: ["GET", "PUT"], + headers: ["*"], + }, + exposeHeaders: [ + "Origin", + "Content-Type", + "Content-MD5", + "Content-Disposition" + ], + maxAgeSeconds: 3600 + } + ] + payload = { + "account_id" => account.id, + "bucket_name" => "a-nice-bucket", + "rules" => rules + } + bucket.create_cors(**payload) + end end ``` From 109032a7d986a1624d348236e5344e6d1169190a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Verg=C3=A9s?= Date: Thu, 12 Jun 2025 19:16:03 +0200 Subject: [PATCH 5/5] add token support --- lib/cloudflare/accounts.rb | 5 ++++ lib/cloudflare/tokens.rb | 57 ++++++++++++++++++++++++++++++++++++++ lib/cloudflare/user.rb | 5 ++++ 3 files changed, 67 insertions(+) create mode 100644 lib/cloudflare/tokens.rb diff --git a/lib/cloudflare/accounts.rb b/lib/cloudflare/accounts.rb index 0334930..a55333f 100644 --- a/lib/cloudflare/accounts.rb +++ b/lib/cloudflare/accounts.rb @@ -9,6 +9,7 @@ require_relative "paginate" require_relative "kv/namespaces" require_relative "r2/buckets" +require_relative "tokens" module Cloudflare class Account < Representation @@ -23,6 +24,10 @@ def kv_namespaces def r2_buckets self.with(R2::Buckets, path: "r2/buckets") end + + def tokens + self.with(Tokens, path: "tokens") + end end class Accounts < Representation diff --git a/lib/cloudflare/tokens.rb b/lib/cloudflare/tokens.rb new file mode 100644 index 0000000..6fc0dca --- /dev/null +++ b/lib/cloudflare/tokens.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2025, by Ivan Vergés. + +require "digest" +require_relative "paginate" +require_relative "representation" + +module Cloudflare + class Token < Representation + include Async::REST::Representation::Mutable + + def name + result[:name] + end + + def id + result[:id] + end + + def secret + Digest::SHA2.hexdigest(result[:value]) + end + + def delete + self.class.delete(@resource.with(path: name)) do |resource, response| + response.success? + end + end + end + + class Tokens < Representation + include Paginate + + def representation + Token + end + + def result + value[:result] + end + + def create(name, **options) + payload = {name: name, **options} + self.class.post(@resource, payload) do |resource, response| + value = response.read + + Token.new(resource, value: value, metadata: response.headers) + end + end + + def find_by_name(name) + each.find {|token| token.name == name } + end + end +end \ No newline at end of file diff --git a/lib/cloudflare/user.rb b/lib/cloudflare/user.rb index a02ea1e..55c69e0 100644 --- a/lib/cloudflare/user.rb +++ b/lib/cloudflare/user.rb @@ -5,6 +5,7 @@ # Copyright, 2018, by Leonhardt Wille. require_relative "representation" +require_relative "tokens" module Cloudflare class User < Representation @@ -15,5 +16,9 @@ def id def email result[:email] end + + def tokens + self.with(Tokens, path: "tokens") + end end end