diff --git a/lib/cloudflare/accounts.rb b/lib/cloudflare/accounts.rb index 7151d00..a55333f 100644 --- a/lib/cloudflare/accounts.rb +++ b/lib/cloudflare/accounts.rb @@ -3,10 +3,13 @@ # 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" +require_relative "tokens" module Cloudflare class Account < Representation @@ -17,6 +20,14 @@ 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 + + def tokens + self.with(Tokens, path: "tokens") + 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..7abd721 --- /dev/null +++ b/lib/cloudflare/r2/buckets.rb @@ -0,0 +1,71 @@ +# 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) + payload = { bucket_name: name, **options} + self.class.put(@resource.with(path: "#{name}/cors"), payload) do |resource, response| + if response.success? + cors + else + raise RequestError.new(resource, response.read) + end + end + end + + def delete + self.class.delete(@resource.with(path: name)) do |resource, response| + response.success? + 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..d9b8b64 --- /dev/null +++ b/lib/cloudflare/r2/cors.rb @@ -0,0 +1,17 @@ +# 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 + 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..3a9b046 --- /dev/null +++ b/lib/cloudflare/r2/domains.rb @@ -0,0 +1,43 @@ +# 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 + 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 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 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/readme.md b/readme.md index 9181b8d..ca1031d 100644 --- a/readme.md +++ b/readme.md @@ -55,12 +55,52 @@ 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 ``` ### 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 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