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 +7 -0
- data/lib/r2store/configuration.rb +10 -0
- data/lib/r2store/errors.rb +6 -0
- data/lib/r2store/manifest.rb +61 -0
- data/lib/r2store/product.rb +12 -0
- data/lib/r2store/storage.rb +100 -0
- data/lib/r2store/webhook.rb +105 -0
- data/lib/r2store.rb +28 -0
- metadata +101 -0
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,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: []
|