renderscreenshot 1.0.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/.pre-commit-config.yaml +40 -0
- data/.rubocop.yml +86 -0
- data/.secrets.baseline +116 -0
- data/CHANGELOG.md +59 -0
- data/Gemfile +17 -0
- data/LICENSE +21 -0
- data/README.md +356 -0
- data/REVIEW.md +125 -0
- data/Rakefile +30 -0
- data/lib/renderscreenshot/cache_manager.rb +63 -0
- data/lib/renderscreenshot/client.rb +201 -0
- data/lib/renderscreenshot/configuration.rb +32 -0
- data/lib/renderscreenshot/error.rb +146 -0
- data/lib/renderscreenshot/http_client.rb +166 -0
- data/lib/renderscreenshot/take_options.rb +442 -0
- data/lib/renderscreenshot/version.rb +5 -0
- data/lib/renderscreenshot/webhook.rb +95 -0
- data/lib/renderscreenshot.rb +25 -0
- metadata +84 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module RenderScreenshot
|
|
7
|
+
# HTTP client wrapper using Faraday
|
|
8
|
+
class HttpClient
|
|
9
|
+
USER_AGENT = "renderscreenshot-ruby/#{VERSION}"
|
|
10
|
+
|
|
11
|
+
# Default base delay for exponential backoff (in seconds)
|
|
12
|
+
DEFAULT_RETRY_DELAY = 1.0
|
|
13
|
+
# Maximum delay between retries (in seconds)
|
|
14
|
+
MAX_RETRY_DELAY = 30.0
|
|
15
|
+
|
|
16
|
+
attr_reader :api_key, :base_url, :timeout, :max_retries, :retry_delay
|
|
17
|
+
|
|
18
|
+
# Initialize a new HTTP client
|
|
19
|
+
# @param api_key [String] API key for authentication
|
|
20
|
+
# @param base_url [String, nil] Custom base URL
|
|
21
|
+
# @param timeout [Integer, nil] Request timeout in seconds
|
|
22
|
+
# @param max_retries [Integer] Maximum number of retries for retryable errors (default: 0)
|
|
23
|
+
# @param retry_delay [Float] Base delay between retries in seconds (default: 1.0)
|
|
24
|
+
def initialize(api_key, base_url: nil, timeout: nil, max_retries: 0, retry_delay: DEFAULT_RETRY_DELAY)
|
|
25
|
+
@api_key = api_key
|
|
26
|
+
@base_url = base_url || RenderScreenshot.configuration.base_url
|
|
27
|
+
@timeout = timeout || RenderScreenshot.configuration.timeout
|
|
28
|
+
@max_retries = max_retries
|
|
29
|
+
@retry_delay = retry_delay
|
|
30
|
+
@connection_mutex = Mutex.new
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def get(path, params: {}, headers: {})
|
|
34
|
+
request(:get, path, params: params, headers: headers)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def get_binary(path, params: {}, headers: {})
|
|
38
|
+
request(:get, path, params: params, headers: headers, binary: true)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def post(path, body: nil, headers: {})
|
|
42
|
+
request(:post, path, body: body, headers: headers)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def post_binary(path, body: nil, headers: {})
|
|
46
|
+
request(:post, path, body: body, headers: headers, binary: true)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def delete(path, params: {}, headers: {})
|
|
50
|
+
request(:delete, path, params: params, headers: headers)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def connection
|
|
56
|
+
@connection || @connection_mutex.synchronize do
|
|
57
|
+
@connection ||= Faraday.new(url: base_url) do |faraday|
|
|
58
|
+
faraday.options.timeout = timeout
|
|
59
|
+
faraday.options.open_timeout = 10
|
|
60
|
+
faraday.adapter Faraday.default_adapter
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def request(method, path, params: {}, body: nil, headers: {}, binary: false)
|
|
66
|
+
attempts = 0
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
attempts += 1
|
|
70
|
+
response = execute_request(method, path, params, body, headers)
|
|
71
|
+
handle_response(response, binary)
|
|
72
|
+
rescue Faraday::TimeoutError
|
|
73
|
+
error = TimeoutError.timeout
|
|
74
|
+
raise error unless should_retry?(error, attempts)
|
|
75
|
+
|
|
76
|
+
sleep_before_retry(error, attempts)
|
|
77
|
+
retry
|
|
78
|
+
rescue Faraday::ConnectionFailed => e
|
|
79
|
+
error = ConnectionError.connection_failed(e.message)
|
|
80
|
+
raise error unless should_retry?(error, attempts)
|
|
81
|
+
|
|
82
|
+
sleep_before_retry(error, attempts)
|
|
83
|
+
retry
|
|
84
|
+
rescue Error => e
|
|
85
|
+
raise e unless should_retry?(e, attempts)
|
|
86
|
+
|
|
87
|
+
sleep_before_retry(e, attempts)
|
|
88
|
+
retry
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def should_retry?(error, attempts)
|
|
93
|
+
return false unless error.retryable?
|
|
94
|
+
return false if attempts > max_retries
|
|
95
|
+
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def sleep_before_retry(error, attempts)
|
|
100
|
+
# Use retry_after header if available (from rate limit errors)
|
|
101
|
+
delay = if error.respond_to?(:retry_after) && error.retry_after
|
|
102
|
+
error.retry_after.to_f
|
|
103
|
+
else
|
|
104
|
+
# Exponential backoff with jitter: base_delay * 2^(attempt-1) + random jitter
|
|
105
|
+
calculated = retry_delay * (2**(attempts - 1))
|
|
106
|
+
jitter = rand * retry_delay * 0.5
|
|
107
|
+
[calculated + jitter, MAX_RETRY_DELAY].min
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
sleep(delay)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def execute_request(method, path, params, body, headers)
|
|
114
|
+
connection.send(method) do |req|
|
|
115
|
+
req.url path
|
|
116
|
+
req.headers['Authorization'] = "Bearer #{api_key}"
|
|
117
|
+
req.headers['User-Agent'] = USER_AGENT
|
|
118
|
+
req.headers['Content-Type'] = 'application/json' if body
|
|
119
|
+
|
|
120
|
+
headers.each { |k, v| req.headers[k] = v }
|
|
121
|
+
req.params = params if params.any?
|
|
122
|
+
req.body = body.is_a?(String) ? body : JSON.generate(body) if body
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def handle_response(response, binary)
|
|
127
|
+
retry_after = parse_retry_after(response.headers['Retry-After'])
|
|
128
|
+
request_id = response.headers['X-Request-Id']
|
|
129
|
+
|
|
130
|
+
unless response.success?
|
|
131
|
+
body = parse_body(response)
|
|
132
|
+
raise Error.from_response(response.status, body, retry_after: retry_after, request_id: request_id)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
if binary
|
|
136
|
+
{
|
|
137
|
+
body: response.body,
|
|
138
|
+
headers: response.headers.to_h
|
|
139
|
+
}
|
|
140
|
+
else
|
|
141
|
+
parse_body(response)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def parse_body(response)
|
|
146
|
+
return {} if response.body.nil? || response.body.empty?
|
|
147
|
+
|
|
148
|
+
content_type = response.headers['Content-Type'] || ''
|
|
149
|
+
if content_type.include?('application/json')
|
|
150
|
+
JSON.parse(response.body)
|
|
151
|
+
else
|
|
152
|
+
response.body
|
|
153
|
+
end
|
|
154
|
+
rescue JSON::ParserError
|
|
155
|
+
response.body
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def parse_retry_after(value)
|
|
159
|
+
return nil unless value
|
|
160
|
+
|
|
161
|
+
Integer(value)
|
|
162
|
+
rescue ArgumentError
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
5
|
+
module RenderScreenshot
|
|
6
|
+
# Immutable fluent builder for screenshot options
|
|
7
|
+
# All methods return a new instance, preserving immutability
|
|
8
|
+
class TakeOptions
|
|
9
|
+
attr_reader :config
|
|
10
|
+
|
|
11
|
+
def initialize(config = {})
|
|
12
|
+
@config = config.dup.freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Factory methods
|
|
16
|
+
def self.url(url)
|
|
17
|
+
new(url: url)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.html(html)
|
|
21
|
+
new(html: html)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.from(hash)
|
|
25
|
+
new(hash)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Viewport methods
|
|
29
|
+
def width(value)
|
|
30
|
+
with(width: value)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def height(value)
|
|
34
|
+
with(height: value)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def scale(value)
|
|
38
|
+
with(scale: value)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def mobile(value = true)
|
|
42
|
+
with(mobile: value)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Capture methods
|
|
46
|
+
def full_page(value = true)
|
|
47
|
+
with(full_page: value)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def element(selector)
|
|
51
|
+
with(element: selector)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def format(value)
|
|
55
|
+
with(format: value)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def quality(value)
|
|
59
|
+
with(quality: value)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Wait methods
|
|
63
|
+
def wait_for(value)
|
|
64
|
+
with(wait_for: value)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def delay(value)
|
|
68
|
+
with(delay: value)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def wait_for_selector(selector)
|
|
72
|
+
with(wait_for_selector: selector)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def wait_for_timeout(value)
|
|
76
|
+
with(wait_for_timeout: value)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Preset methods
|
|
80
|
+
def preset(value)
|
|
81
|
+
with(preset: value)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def device(value)
|
|
85
|
+
with(device: value)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Blocking methods
|
|
89
|
+
def block_ads(value = true)
|
|
90
|
+
with(block_ads: value)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def block_trackers(value = true)
|
|
94
|
+
with(block_trackers: value)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def block_cookie_banners(value = true)
|
|
98
|
+
with(block_cookie_banners: value)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def block_chat_widgets(value = true)
|
|
102
|
+
with(block_chat_widgets: value)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def block_urls(patterns)
|
|
106
|
+
with(block_urls: patterns)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def block_resources(types)
|
|
110
|
+
with(block_resources: types)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Page manipulation methods
|
|
114
|
+
def inject_script(script)
|
|
115
|
+
with(inject_script: script)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def inject_style(style)
|
|
119
|
+
with(inject_style: style)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def click(selector)
|
|
123
|
+
with(click: selector)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def hide(selectors)
|
|
127
|
+
with(hide: Array(selectors))
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def remove(selectors)
|
|
131
|
+
with(remove: Array(selectors))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Browser emulation methods
|
|
135
|
+
def dark_mode(value = true)
|
|
136
|
+
with(dark_mode: value)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def reduced_motion(value = true)
|
|
140
|
+
with(reduced_motion: value)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def media_type(value)
|
|
144
|
+
with(media_type: value)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def user_agent(value)
|
|
148
|
+
with(user_agent: value)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def timezone(value)
|
|
152
|
+
with(timezone: value)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def locale(value)
|
|
156
|
+
with(locale: value)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def geolocation(latitude, longitude, accuracy: nil)
|
|
160
|
+
geo = { latitude: latitude, longitude: longitude }
|
|
161
|
+
geo[:accuracy] = accuracy if accuracy
|
|
162
|
+
with(geolocation: geo)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Network methods
|
|
166
|
+
def headers(value)
|
|
167
|
+
with(headers: value)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def cookies(value)
|
|
171
|
+
with(cookies: value)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def auth_basic(username, password)
|
|
175
|
+
with(auth_basic: { username: username, password: password })
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def auth_bearer(token)
|
|
179
|
+
with(auth_bearer: token)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def bypass_csp(value = true)
|
|
183
|
+
with(bypass_csp: value)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Cache methods
|
|
187
|
+
def cache_ttl(value)
|
|
188
|
+
with(cache_ttl: value)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def cache_refresh(value = true)
|
|
192
|
+
with(cache_refresh: value)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# PDF methods
|
|
196
|
+
def pdf_paper_size(value)
|
|
197
|
+
with(pdf_paper_size: value)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def pdf_width(value)
|
|
201
|
+
with(pdf_width: value)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def pdf_height(value)
|
|
205
|
+
with(pdf_height: value)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def pdf_landscape(value = true)
|
|
209
|
+
with(pdf_landscape: value)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def pdf_margin(value)
|
|
213
|
+
with(pdf_margin: value)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def pdf_margin_top(value)
|
|
217
|
+
with(pdf_margin_top: value)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def pdf_margin_right(value)
|
|
221
|
+
with(pdf_margin_right: value)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def pdf_margin_bottom(value)
|
|
225
|
+
with(pdf_margin_bottom: value)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def pdf_margin_left(value)
|
|
229
|
+
with(pdf_margin_left: value)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def pdf_scale(value)
|
|
233
|
+
with(pdf_scale: value)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def pdf_print_background(value = true)
|
|
237
|
+
with(pdf_print_background: value)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def pdf_page_ranges(value)
|
|
241
|
+
with(pdf_page_ranges: value)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def pdf_header(value)
|
|
245
|
+
with(pdf_header: value)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def pdf_footer(value)
|
|
249
|
+
with(pdf_footer: value)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def pdf_fit_one_page(value = true)
|
|
253
|
+
with(pdf_fit_one_page: value)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def pdf_prefer_css_page_size(value = true)
|
|
257
|
+
with(pdf_prefer_css_page_size: value)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Storage methods
|
|
261
|
+
def storage_enabled(value = true)
|
|
262
|
+
with(storage_enabled: value)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def storage_path(value)
|
|
266
|
+
with(storage_path: value)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def storage_acl(value)
|
|
270
|
+
with(storage_acl: value)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Output methods
|
|
274
|
+
def to_h
|
|
275
|
+
config.dup
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Convert to nested JSON params for POST requests
|
|
279
|
+
def to_params
|
|
280
|
+
result = {}
|
|
281
|
+
|
|
282
|
+
# Top-level params
|
|
283
|
+
%i[url html preset].each do |key|
|
|
284
|
+
result[key] = config[key] if config.key?(key)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Viewport group
|
|
288
|
+
viewport = {}
|
|
289
|
+
{ width: :width, height: :height, scale: :scale, mobile: :mobile, device: :device }.each do |config_key, api_key|
|
|
290
|
+
viewport[api_key] = config[config_key] if config.key?(config_key)
|
|
291
|
+
end
|
|
292
|
+
result[:viewport] = viewport unless viewport.empty?
|
|
293
|
+
|
|
294
|
+
# Capture group
|
|
295
|
+
capture = {}
|
|
296
|
+
capture[:mode] = 'full_page' if config[:full_page]
|
|
297
|
+
capture[:selector] = config[:element] if config[:element]
|
|
298
|
+
result[:capture] = capture unless capture.empty?
|
|
299
|
+
|
|
300
|
+
# Output group
|
|
301
|
+
output = {}
|
|
302
|
+
output[:format] = config[:format] if config[:format]
|
|
303
|
+
output[:quality] = config[:quality] if config[:quality]
|
|
304
|
+
result[:output] = output unless output.empty?
|
|
305
|
+
|
|
306
|
+
# Wait group
|
|
307
|
+
wait = {}
|
|
308
|
+
wait[:until] = config[:wait_for] if config[:wait_for]
|
|
309
|
+
wait[:delay] = config[:delay] if config[:delay]
|
|
310
|
+
wait[:for_selector] = config[:wait_for_selector] if config[:wait_for_selector]
|
|
311
|
+
wait[:timeout] = config[:wait_for_timeout] if config[:wait_for_timeout]
|
|
312
|
+
result[:wait] = wait unless wait.empty?
|
|
313
|
+
|
|
314
|
+
# Block group
|
|
315
|
+
block = {}
|
|
316
|
+
block[:ads] = config[:block_ads] if config.key?(:block_ads)
|
|
317
|
+
block[:trackers] = config[:block_trackers] if config.key?(:block_trackers)
|
|
318
|
+
block[:cookie_banners] = config[:block_cookie_banners] if config.key?(:block_cookie_banners)
|
|
319
|
+
block[:chat_widgets] = config[:block_chat_widgets] if config.key?(:block_chat_widgets)
|
|
320
|
+
block[:requests] = config[:block_urls] if config[:block_urls]
|
|
321
|
+
block[:resources] = config[:block_resources] if config[:block_resources]
|
|
322
|
+
result[:block] = block unless block.empty?
|
|
323
|
+
|
|
324
|
+
# Page group
|
|
325
|
+
page = {}
|
|
326
|
+
page[:scripts] = [config[:inject_script]] if config[:inject_script]
|
|
327
|
+
page[:styles] = [config[:inject_style]] if config[:inject_style]
|
|
328
|
+
page[:click] = config[:click] if config[:click]
|
|
329
|
+
page[:hide] = config[:hide] if config[:hide]
|
|
330
|
+
page[:remove] = config[:remove] if config[:remove]
|
|
331
|
+
result[:page] = page unless page.empty?
|
|
332
|
+
|
|
333
|
+
# Browser group
|
|
334
|
+
browser = {}
|
|
335
|
+
browser[:dark_mode] = config[:dark_mode] if config.key?(:dark_mode)
|
|
336
|
+
browser[:reduced_motion] = config[:reduced_motion] if config.key?(:reduced_motion)
|
|
337
|
+
browser[:media] = config[:media_type] if config[:media_type]
|
|
338
|
+
browser[:user_agent] = config[:user_agent] if config[:user_agent]
|
|
339
|
+
browser[:timezone] = config[:timezone] if config[:timezone]
|
|
340
|
+
browser[:locale] = config[:locale] if config[:locale]
|
|
341
|
+
browser[:geolocation] = config[:geolocation] if config[:geolocation]
|
|
342
|
+
result[:browser] = browser unless browser.empty?
|
|
343
|
+
|
|
344
|
+
# Network group
|
|
345
|
+
network = {}
|
|
346
|
+
network[:headers] = config[:headers] if config[:headers]
|
|
347
|
+
network[:cookies] = config[:cookies] if config[:cookies]
|
|
348
|
+
network[:bypass_csp] = config[:bypass_csp] if config.key?(:bypass_csp)
|
|
349
|
+
if config[:auth_basic]
|
|
350
|
+
network[:auth] = { type: 'basic' }.merge(config[:auth_basic])
|
|
351
|
+
elsif config[:auth_bearer]
|
|
352
|
+
network[:auth] = { type: 'bearer', token: config[:auth_bearer] }
|
|
353
|
+
end
|
|
354
|
+
result[:network] = network unless network.empty?
|
|
355
|
+
|
|
356
|
+
# Cache group
|
|
357
|
+
cache = {}
|
|
358
|
+
cache[:ttl] = config[:cache_ttl] if config[:cache_ttl]
|
|
359
|
+
cache[:refresh] = config[:cache_refresh] if config.key?(:cache_refresh)
|
|
360
|
+
result[:cache] = cache unless cache.empty?
|
|
361
|
+
|
|
362
|
+
# PDF group
|
|
363
|
+
pdf = {}
|
|
364
|
+
pdf[:paper] = config[:pdf_paper_size] if config[:pdf_paper_size]
|
|
365
|
+
pdf[:width] = config[:pdf_width] if config[:pdf_width]
|
|
366
|
+
pdf[:height] = config[:pdf_height] if config[:pdf_height]
|
|
367
|
+
pdf[:landscape] = config[:pdf_landscape] if config.key?(:pdf_landscape)
|
|
368
|
+
pdf[:scale] = config[:pdf_scale] if config[:pdf_scale]
|
|
369
|
+
pdf[:background] = config[:pdf_print_background] if config.key?(:pdf_print_background)
|
|
370
|
+
pdf[:page_ranges] = config[:pdf_page_ranges] if config[:pdf_page_ranges]
|
|
371
|
+
pdf[:header] = config[:pdf_header] if config[:pdf_header]
|
|
372
|
+
pdf[:footer] = config[:pdf_footer] if config[:pdf_footer]
|
|
373
|
+
pdf[:fit_one_page] = config[:pdf_fit_one_page] if config.key?(:pdf_fit_one_page)
|
|
374
|
+
pdf[:prefer_css_page_size] = config[:pdf_prefer_css_page_size] if config.key?(:pdf_prefer_css_page_size)
|
|
375
|
+
|
|
376
|
+
# PDF margins - supports uniform margin (string like "2cm") or individual margins (hash)
|
|
377
|
+
if config[:pdf_margin]
|
|
378
|
+
# Uniform margin specified - use directly (string or hash)
|
|
379
|
+
pdf[:margin] = config[:pdf_margin]
|
|
380
|
+
else
|
|
381
|
+
# Build margin hash from individual margin settings
|
|
382
|
+
margin = {}
|
|
383
|
+
margin[:top] = config[:pdf_margin_top] if config[:pdf_margin_top]
|
|
384
|
+
margin[:right] = config[:pdf_margin_right] if config[:pdf_margin_right]
|
|
385
|
+
margin[:bottom] = config[:pdf_margin_bottom] if config[:pdf_margin_bottom]
|
|
386
|
+
margin[:left] = config[:pdf_margin_left] if config[:pdf_margin_left]
|
|
387
|
+
pdf[:margin] = margin unless margin.empty?
|
|
388
|
+
end
|
|
389
|
+
result[:pdf] = pdf unless pdf.empty?
|
|
390
|
+
|
|
391
|
+
# Storage group
|
|
392
|
+
storage = {}
|
|
393
|
+
storage[:enabled] = config[:storage_enabled] if config.key?(:storage_enabled)
|
|
394
|
+
storage[:path] = config[:storage_path] if config[:storage_path]
|
|
395
|
+
storage[:acl] = config[:storage_acl] if config[:storage_acl]
|
|
396
|
+
result[:storage] = storage unless storage.empty?
|
|
397
|
+
|
|
398
|
+
result
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Convert to flat query string for GET requests
|
|
402
|
+
def to_query_string
|
|
403
|
+
params = []
|
|
404
|
+
|
|
405
|
+
# Map config keys to query param names
|
|
406
|
+
query_mappings = {
|
|
407
|
+
url: 'url',
|
|
408
|
+
html: 'html',
|
|
409
|
+
preset: 'preset',
|
|
410
|
+
width: 'width',
|
|
411
|
+
height: 'height',
|
|
412
|
+
device: 'device',
|
|
413
|
+
scale: 'scale',
|
|
414
|
+
full_page: 'full_page',
|
|
415
|
+
element: 'selector',
|
|
416
|
+
format: 'format',
|
|
417
|
+
quality: 'quality',
|
|
418
|
+
delay: 'delay',
|
|
419
|
+
wait_for_timeout: 'timeout',
|
|
420
|
+
block_ads: 'block_ads',
|
|
421
|
+
block_cookie_banners: 'block_cookies',
|
|
422
|
+
dark_mode: 'dark_mode',
|
|
423
|
+
cache_ttl: 'cache_ttl'
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
query_mappings.each do |config_key, param_name|
|
|
427
|
+
next unless config.key?(config_key)
|
|
428
|
+
|
|
429
|
+
value = config[config_key]
|
|
430
|
+
params << "#{param_name}=#{CGI.escape(value.to_s)}"
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
params.join('&')
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
private
|
|
437
|
+
|
|
438
|
+
def with(updates)
|
|
439
|
+
self.class.new(config.merge(updates))
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module RenderScreenshot
|
|
7
|
+
# Webhook verification and parsing utilities
|
|
8
|
+
module Webhook
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
SIGNATURE_HEADER = 'X-Webhook-Signature'
|
|
12
|
+
TIMESTAMP_HEADER = 'X-Webhook-Timestamp'
|
|
13
|
+
ID_HEADER = 'X-Webhook-ID'
|
|
14
|
+
DEFAULT_TOLERANCE = 300 # 5 minutes
|
|
15
|
+
|
|
16
|
+
# Verify webhook signature using HMAC-SHA256
|
|
17
|
+
# @param payload [String] Raw request body
|
|
18
|
+
# @param signature [String] Signature from header (format: "sha256=...")
|
|
19
|
+
# @param timestamp [String] Timestamp from header
|
|
20
|
+
# @param secret [String] Webhook secret
|
|
21
|
+
# @param tolerance [Integer] Max age in seconds (default 300)
|
|
22
|
+
# @return [Boolean] true if valid
|
|
23
|
+
def verify(payload:, signature:, timestamp:, secret:, tolerance: DEFAULT_TOLERANCE)
|
|
24
|
+
return false if payload.nil? || signature.nil? || timestamp.nil? || secret.nil?
|
|
25
|
+
|
|
26
|
+
# Check timestamp is within tolerance (replay attack prevention)
|
|
27
|
+
ts = Integer(timestamp)
|
|
28
|
+
age = Time.now.to_i - ts
|
|
29
|
+
return false if age.abs > tolerance
|
|
30
|
+
|
|
31
|
+
# Compute expected signature
|
|
32
|
+
signed_payload = "#{timestamp}.#{payload}"
|
|
33
|
+
expected_hash = OpenSSL::HMAC.hexdigest('SHA256', secret, signed_payload)
|
|
34
|
+
expected = "sha256=#{expected_hash}"
|
|
35
|
+
|
|
36
|
+
# Timing-safe comparison
|
|
37
|
+
secure_compare(expected, signature)
|
|
38
|
+
rescue ArgumentError
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Parse webhook payload into structured data
|
|
43
|
+
# @param payload [String, Hash] JSON payload or parsed hash
|
|
44
|
+
# @return [Hash] { event: String, id: String, timestamp: Integer, data: Hash }
|
|
45
|
+
def parse(payload)
|
|
46
|
+
data = payload.is_a?(String) ? JSON.parse(payload) : payload
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
event: data['type'] || data['event'],
|
|
50
|
+
id: data['id'],
|
|
51
|
+
timestamp: data['timestamp'],
|
|
52
|
+
data: data['data'] || {}
|
|
53
|
+
}
|
|
54
|
+
rescue JSON::ParserError
|
|
55
|
+
raise ValidationError.invalid_request('Invalid webhook payload')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Extract signature, timestamp, and ID from request headers
|
|
59
|
+
# @param headers [Hash] Request headers
|
|
60
|
+
# @return [Hash] { signature:, timestamp:, id: }
|
|
61
|
+
def extract_headers(headers)
|
|
62
|
+
# Normalize header keys (handle different header formats)
|
|
63
|
+
normalized = normalize_headers(headers)
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
signature: normalized['x-webhook-signature'],
|
|
67
|
+
timestamp: normalized['x-webhook-timestamp'],
|
|
68
|
+
id: normalized['x-webhook-id']
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Timing-safe string comparison
|
|
73
|
+
def secure_compare(a, b)
|
|
74
|
+
return false unless a.bytesize == b.bytesize
|
|
75
|
+
|
|
76
|
+
l = a.unpack('C*')
|
|
77
|
+
res = 0
|
|
78
|
+
b.each_byte { |byte| res |= byte ^ l.shift }
|
|
79
|
+
res.zero?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private_class_method :secure_compare
|
|
83
|
+
|
|
84
|
+
def normalize_headers(headers)
|
|
85
|
+
result = {}
|
|
86
|
+
headers.each do |key, value|
|
|
87
|
+
normalized_key = key.to_s.downcase.tr('_', '-')
|
|
88
|
+
result[normalized_key] = value
|
|
89
|
+
end
|
|
90
|
+
result
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private_class_method :normalize_headers
|
|
94
|
+
end
|
|
95
|
+
end
|