r2store.rb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b97625de127487dcfefd8fafeda4cc9ef4d25baaabceab605f125a32b0a6a238
4
+ data.tar.gz: d675b5461d2be041dbddab00cecda1fca934ca705569ba2e67747084aa5d1459
5
+ SHA512:
6
+ metadata.gz: 0a23dca220a9883a0c4477716bfb1a399a90ab362574ce0f44851533c416ee299ab92ad9e5c1937257bf885dab5f4a250cd28008e4b0d5f7ccd74bdf31bd3088
7
+ data.tar.gz: f08d8a2c80e20bcec42bdca9504389cffd325be1e75ca1899e97a36632f78d25a50a1175cea43b6715fc6f43abee2026aea9a0d9b8ddd9462e8fb32dd4c0f266
@@ -0,0 +1,10 @@
1
+ module R2Store
2
+ class Configuration
3
+ attr_accessor :endpoint, :access_key_id, :secret_access_key,
4
+ :bucket, :region, :webhook_secret
5
+
6
+ def initialize
7
+ @region = "auto"
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,6 @@
1
+ module R2Store
2
+ class Error < StandardError; end
3
+ class PathTraversalError < Error; end
4
+ class WebhookVerificationError < Error; end
5
+ class ManifestError < Error; end
6
+ end
@@ -0,0 +1,61 @@
1
+ module R2Store
2
+ module Manifest
3
+ REQUIRED_KEYS = %w[title description price currency files previews].freeze
4
+ OPTIONAL_KEYS = %w[tags draft].freeze
5
+ ALLOWED_KEYS = (REQUIRED_KEYS + OPTIONAL_KEYS).freeze
6
+ VALID_CURRENCIES = %w[USDC EURC].freeze
7
+
8
+ class << self
9
+ def parse(yaml_string)
10
+ validate!(yaml_string)
11
+ end
12
+
13
+ def validate!(yaml_string)
14
+ data = YAML.safe_load(yaml_string)
15
+ raise ManifestError, "manifest must be a YAML mapping" unless data.is_a?(Hash)
16
+
17
+ unknown = data.keys - ALLOWED_KEYS
18
+ unless unknown.empty?
19
+ raise ManifestError, "unknown keys: #{unknown.join(', ')}"
20
+ end
21
+
22
+ REQUIRED_KEYS.each do |key|
23
+ raise ManifestError, "missing required field: #{key}" unless data.key?(key)
24
+ end
25
+
26
+ raise ManifestError, "title must be a string" unless data["title"].is_a?(String)
27
+ raise ManifestError, "description must be a string" unless data["description"].is_a?(String)
28
+
29
+ unless data["price"].is_a?(Numeric) && data["price"] > 0
30
+ raise ManifestError, "price must be a positive number"
31
+ end
32
+
33
+ unless VALID_CURRENCIES.include?(data["currency"])
34
+ raise ManifestError, "currency must be one of: #{VALID_CURRENCIES.join(', ')}"
35
+ end
36
+
37
+ raise ManifestError, "files must be an array" unless data["files"].is_a?(Array)
38
+ raise ManifestError, "previews must be an array" unless data["previews"].is_a?(Array)
39
+
40
+ if data.key?("tags") && !data["tags"].is_a?(Array)
41
+ raise ManifestError, "tags must be an array"
42
+ end
43
+
44
+ if data.key?("draft") && ![true, false].include?(data["draft"])
45
+ raise ManifestError, "draft must be a boolean"
46
+ end
47
+
48
+ Product.new(
49
+ title: data["title"],
50
+ description: data["description"],
51
+ price: data["price"],
52
+ currency: data["currency"],
53
+ files: data["files"],
54
+ previews: data["previews"],
55
+ tags: data["tags"] || [],
56
+ draft: data.fetch("draft", false)
57
+ )
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,12 @@
1
+ module R2Store
2
+ Product = Struct.new(:title, :description, :price, :currency,
3
+ :files, :previews, :tags, :draft, keyword_init: true) do
4
+ def initialize(**)
5
+ super
6
+ self.files ||= []
7
+ self.previews ||= []
8
+ self.tags ||= []
9
+ self.draft = false if self.draft.nil?
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,100 @@
1
+ module R2Store
2
+ module Storage
3
+ class << self
4
+ def presigned_put(seller:, path:, content_type:, max_size:, expires_in: 3600)
5
+ key = scoped_key(seller, path)
6
+ presigner.presigned_url(:put_object,
7
+ bucket: config.bucket,
8
+ key: key,
9
+ content_type: content_type,
10
+ content_length: max_size,
11
+ expires_in: expires_in
12
+ )
13
+ end
14
+
15
+ def presigned_get(seller:, path:, expires_in: 3600)
16
+ key = scoped_key(seller, path)
17
+ presigner.presigned_url(:get_object,
18
+ bucket: config.bucket,
19
+ key: key,
20
+ expires_in: expires_in
21
+ )
22
+ end
23
+
24
+ def head(seller:, path:)
25
+ key = scoped_key(seller, path)
26
+ resp = client.head_object(bucket: config.bucket, key: key)
27
+ {
28
+ size: resp.content_length,
29
+ etag: resp.etag,
30
+ content_type: resp.content_type
31
+ }
32
+ rescue Aws::S3::Errors::NotFound
33
+ nil
34
+ end
35
+
36
+ def fetch(seller:, path:)
37
+ key = scoped_key(seller, path)
38
+ resp = client.get_object(bucket: config.bucket, key: key)
39
+ resp.body.read
40
+ end
41
+
42
+ def delete(seller:, path:)
43
+ key = scoped_key(seller, path)
44
+ client.delete_object(bucket: config.bucket, key: key)
45
+ end
46
+
47
+ def list(seller:, prefix: nil)
48
+ full_prefix = "sellers/#{seller}/"
49
+ full_prefix += prefix if prefix
50
+ validate_path!(full_prefix)
51
+
52
+ resp = client.list_objects_v2(bucket: config.bucket, prefix: full_prefix)
53
+ resp.contents.map do |obj|
54
+ { key: obj.key, size: obj.size, etag: obj.etag }
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def scoped_key(seller, path)
61
+ validate_path!(path)
62
+ "sellers/#{seller}/#{path}"
63
+ end
64
+
65
+ def validate_path!(path)
66
+ raise PathTraversalError, "path traversal not allowed" if path.include?("..")
67
+ end
68
+
69
+ def config
70
+ R2Store.configuration
71
+ end
72
+
73
+ def client
74
+ @client = nil if @client_config != client_config_hash
75
+ @client_config = client_config_hash
76
+ @client ||= Aws::S3::Client.new(s3_client_options)
77
+ end
78
+
79
+ def presigner
80
+ @presigner = nil if @presigner_config != client_config_hash
81
+ @presigner_config = client_config_hash
82
+ @presigner ||= Aws::S3::Presigner.new(client: client)
83
+ end
84
+
85
+ def client_config_hash
86
+ [config.endpoint, config.access_key_id, config.secret_access_key, config.region]
87
+ end
88
+
89
+ def s3_client_options
90
+ {
91
+ endpoint: config.endpoint,
92
+ access_key_id: config.access_key_id,
93
+ secret_access_key: config.secret_access_key,
94
+ region: config.region,
95
+ force_path_style: true
96
+ }
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,105 @@
1
+ module R2Store
2
+ module Webhook
3
+ Event = Struct.new(:type, :key, :size, :etag, :bucket, keyword_init: true) do
4
+ def seller
5
+ parts = key.split("/")
6
+ return parts[1] if parts[0] == "sellers" && parts.length >= 3
7
+ end
8
+
9
+ def path
10
+ parts = key.split("/")
11
+ return parts[2..].join("/") if parts[0] == "sellers" && parts.length >= 3
12
+ end
13
+
14
+ def product_manifest?
15
+ path&.end_with?("product.yml") || false
16
+ end
17
+
18
+ def preview?
19
+ p = path
20
+ return false unless p
21
+
22
+ # Files under a previews/ subfolder with image extensions
23
+ if p.match?(%r{(?:^|/)previews/.+\.(?:png|jpg|jpeg|gif|webp|svg)\z}i)
24
+ return true
25
+ end
26
+
27
+ # Files named preview.* with image extensions
28
+ if p.match?(%r{(?:^|/)preview\.(?:png|jpg|jpeg|gif|webp|svg)\z}i)
29
+ return true
30
+ end
31
+
32
+ false
33
+ end
34
+ end
35
+
36
+ EVENT_TYPE_MAP = {
37
+ "s3:ObjectCreated:*" => :object_created,
38
+ "s3:ObjectCreated:Put" => :object_created,
39
+ "s3:ObjectCreated:CompleteMultipartUpload" => :object_created,
40
+ "s3:ObjectRemoved:*" => :object_deleted,
41
+ "s3:ObjectRemoved:Delete" => :object_deleted
42
+ }.freeze
43
+
44
+ class << self
45
+ def process(request, secret: nil)
46
+ signing_secret = secret || R2Store.configuration.webhook_secret
47
+ body = read_body(request)
48
+ signature = extract_signature(request)
49
+
50
+ verify_signature!(body, signature, signing_secret)
51
+
52
+ payload = JSON.parse(body)
53
+ event = build_event(payload)
54
+ yield event if block_given?
55
+ event
56
+ end
57
+
58
+ private
59
+
60
+ def read_body(request)
61
+ if request.respond_to?(:body)
62
+ b = request.body
63
+ b.rewind if b.respond_to?(:rewind)
64
+ b.read
65
+ else
66
+ request.to_s
67
+ end
68
+ end
69
+
70
+ def extract_signature(request)
71
+ if request.respond_to?(:get_header)
72
+ request.get_header("HTTP_X_WEBHOOK_SIGNATURE")
73
+ elsif request.respond_to?(:[])
74
+ request["HTTP_X_WEBHOOK_SIGNATURE"] || request["X-Webhook-Signature"]
75
+ end
76
+ end
77
+
78
+ def verify_signature!(body, signature, secret)
79
+ raise WebhookVerificationError, "missing signature" unless signature
80
+ raise WebhookVerificationError, "missing webhook secret" unless secret
81
+
82
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret, body)
83
+ unless Rack::Utils.secure_compare(expected, signature)
84
+ raise WebhookVerificationError, "signature mismatch"
85
+ end
86
+ end
87
+
88
+ def build_event(payload)
89
+ record = payload.is_a?(Hash) && payload["Records"] ? payload["Records"][0] : payload
90
+ event_name = record.dig("eventName") || record.dig("event_name")
91
+ s3_data = record["s3"] || {}
92
+ object_data = s3_data["object"] || record.fetch("object", {})
93
+ bucket_data = s3_data["bucket"] || record.fetch("bucket", {})
94
+
95
+ Event.new(
96
+ type: EVENT_TYPE_MAP.fetch(event_name, event_name&.to_sym),
97
+ key: object_data["key"],
98
+ size: object_data["size"],
99
+ etag: object_data["eTag"] || object_data["etag"],
100
+ bucket: bucket_data["name"] || bucket_data.to_s
101
+ )
102
+ end
103
+ end
104
+ end
105
+ end
data/lib/r2store.rb ADDED
@@ -0,0 +1,28 @@
1
+ require "aws-sdk-s3"
2
+ require "json"
3
+ require "yaml"
4
+ require "openssl"
5
+ require "rack"
6
+
7
+ require_relative "r2store/errors"
8
+ require_relative "r2store/configuration"
9
+ require_relative "r2store/product"
10
+ require_relative "r2store/storage"
11
+ require_relative "r2store/webhook"
12
+ require_relative "r2store/manifest"
13
+
14
+ module R2Store
15
+ class << self
16
+ def configuration
17
+ @configuration ||= Configuration.new
18
+ end
19
+
20
+ def configure
21
+ yield(configuration)
22
+ end
23
+
24
+ def reset_configuration!
25
+ @configuration = Configuration.new
26
+ end
27
+ end
28
+ end
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: r2store.rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Filippo
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: aws-sdk-s3
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rack
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rexml
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rspec
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.0'
68
+ description: Manages presigned uploads/downloads, webhook processing, and product
69
+ manifest parsing for Cloudflare R2.
70
+ executables: []
71
+ extensions: []
72
+ extra_rdoc_files: []
73
+ files:
74
+ - lib/r2store.rb
75
+ - lib/r2store/configuration.rb
76
+ - lib/r2store/errors.rb
77
+ - lib/r2store/manifest.rb
78
+ - lib/r2store/product.rb
79
+ - lib/r2store/storage.rb
80
+ - lib/r2store/webhook.rb
81
+ licenses:
82
+ - MIT
83
+ metadata: {}
84
+ rdoc_options: []
85
+ require_paths:
86
+ - lib
87
+ required_ruby_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '3.1'
92
+ required_rubygems_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ requirements: []
98
+ rubygems_version: 4.0.8
99
+ specification_version: 4
100
+ summary: Digital product storage on Cloudflare R2
101
+ test_files: []