├── test
├── .gitkeep
├── test_urlbox_webhook_validator.rb
└── test_urlbox_client.rb
├── .gitignore
├── Rakefile
├── .github
├── pull_request_template.md
└── workflows
│ ├── tests.yml
│ └── linters.yml
├── Gemfile
├── lib
└── urlbox
│ ├── errors.rb
│ ├── webhook_validator.rb
│ └── client.rb
├── .rubocop.yml
├── LICENSE.txt
├── urlbox.gemspec
└── README.md
/test/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | urlbox-*.gem
2 | Gemfile.lock
3 | /.bundle/
4 | coverage/
5 | .idea/
6 |
7 | # OS generated files
8 | .DS_Store
9 | .DS_Store?
10 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | require 'rake/testtask'
2 |
3 | Rake::TestTask.new do |t|
4 | t.libs << 'test'
5 | end
6 |
7 | desc 'Run tests'
8 | task default: :test
9 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | #### What's this PR do?
2 |
3 | #### Background context
4 |
5 | #### Where should the reviewer start?
6 |
7 | #### How should this be manually tested?
8 |
9 | #### Screenshots
10 |
11 | ---
12 |
13 | #### Additional notes
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source 'https://rubygems.org'
4 |
5 | gemspec
6 |
7 | gem 'http', '~> 5.0'
8 | gem 'openssl', '~> 2.2'
9 |
10 | group :development do
11 | gem 'minitest'
12 | gem 'rake'
13 | gem 'rubocop', '~> 1.23', require: false
14 | end
15 |
16 | group :test do
17 | gem 'climate_control'
18 | gem 'simplecov', require: false
19 | gem 'webmock'
20 | end
21 |
--------------------------------------------------------------------------------
/lib/urlbox/errors.rb:
--------------------------------------------------------------------------------
1 | module Urlbox
2 | class Error < StandardError
3 | def self.missing_api_secret_error_message
4 | <<-ERROR_MESSAGE
5 | Missing api_secret when initialising client or ENV['URLBOX_API_SECRET'] not set.
6 | Required for authorised post request.
7 | ERROR_MESSAGE
8 | end
9 | end
10 |
11 | class InvalidHeaderSignatureError < StandardError; end
12 | end
13 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yml:
--------------------------------------------------------------------------------
1 | # ref:
2 | # https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-ruby
3 | name: Ruby CI
4 | on: [pull_request]
5 | jobs:
6 | test:
7 | runs-on: ubuntu-latest
8 | strategy:
9 | matrix:
10 | ruby-version: ['3.0', 2.7.3, 2.6.7, 2.5.9]
11 | steps:
12 | - uses: actions/checkout@v2
13 | - name: Set up Ruby ${{ matrix.ruby-version }}
14 | uses: ruby/setup-ruby@v1
15 | with:
16 | ruby-version: ${{ matrix.ruby-version }}
17 | bundler-cache: true
18 | - name: Run the default task
19 | run: bundle exec rake
20 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | ---
2 | AllCops:
3 | Exclude:
4 | - '*.gemspec'
5 | TargetRubyVersion: 2.5
6 | Layout/LineLength:
7 | Enabled: true
8 | Exclude:
9 | - test/**/*
10 | Metrics/BlockLength:
11 | Enabled: true
12 | Exclude:
13 | - test/**/*
14 | Metrics/ClassLength:
15 | Enabled: true
16 | Exclude:
17 | - test/**/*
18 | Metrics/MethodLength:
19 | Enabled: true
20 | Exclude:
21 | - test/**/*
22 | Style/Documentation:
23 | Enabled: false
24 | Style/FrozenStringLiteralComment:
25 | Enabled: false
26 | Exclude:
27 | Style/GuardClause:
28 | Enabled: false
29 | Style/StringLiterals:
30 | Enabled: true
31 | Exclude:
32 | - test/**/*
33 |
--------------------------------------------------------------------------------
/.github/workflows/linters.yml:
--------------------------------------------------------------------------------
1 | # This workflow uses actions that are not certified by GitHub.
2 | # They are provided by a third-party and are governed by
3 | # separate terms of service, privacy policy, and support
4 | # documentation.
5 | # ref:
6 | # https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-ruby
7 |
8 | name: Linting
9 |
10 | on: [pull_request]
11 |
12 | jobs:
13 | lint:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v2
17 | - uses: ruby/setup-ruby@477b21f02be01bcb8030d50f37cfec92bfa615b6
18 | with:
19 | ruby-version: 3.0.1
20 | - run: bundle install
21 | - name: Rubocop
22 | run: rubocop
23 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2021 Urlbox Ltd.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/urlbox.gemspec:
--------------------------------------------------------------------------------
1 | lib = File.expand_path('lib', __dir__)
2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3 |
4 | Gem::Specification.new do |spec|
5 | spec.name = 'urlbox'
6 | spec.version = '0.1.3'
7 | spec.authors = ['Urlbox']
8 | spec.email = ['support@urlbox.com']
9 |
10 | spec.summary = 'Ruby wrapper for the Urlbox API'
11 | spec.description = 'Urlbox is the easiest, quickest, screenshot API. ' \
12 | "See https://urlbox.com for details."
13 | spec.homepage = 'https://urlbox.com'
14 | spec.license = 'MIT'
15 |
16 | spec.metadata = {
17 | "bug_tracker_uri" => "https://github.com/urlbox/urlbox-ruby/issues",
18 | "documentation_uri" => "https://github.com/urlbox/urlbox-ruby",
19 | "github_repo" => "https://github.com/urlbox/urlbox-ruby",
20 | "homepage_uri" => "https://github.com/urlbox/urlbox-ruby",
21 | "source_code_uri" => "https://github.com/urlbox/urlbox-ruby",
22 | }
23 |
24 | # If you need to check in files that aren't .rb files, add them here
25 | spec.files = Dir['{lib}/**/*.rb', 'bin/*', 'LICENSE', '*.md']
26 | spec.bindir = 'exe'
27 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
28 | spec.require_paths = ['lib']
29 |
30 | spec.required_ruby_version = '>= 2.5'
31 |
32 | spec.add_runtime_dependency 'http', ['~> 5.0']
33 | spec.add_runtime_dependency 'openssl', ['~> 2.2']
34 | end
35 |
--------------------------------------------------------------------------------
/lib/urlbox/webhook_validator.rb:
--------------------------------------------------------------------------------
1 | require 'urlbox/errors'
2 |
3 | module Urlbox
4 | class WebhookValidator
5 | SIGNATURE_REGEX = /^sha256=[0-9a-zA-Z]{40,}$/.freeze
6 | TIMESTAMP_REGEX = /^t=[0-9]+$/.freeze
7 | WEBHOOK_AGE_MAX_MINUTES = 5
8 |
9 | class << self
10 | def call(header_signature, payload, webhook_secret)
11 | timestamp, signature = header_signature.split(',')
12 |
13 | check_timestamp(timestamp)
14 | check_signature(signature, timestamp, payload, webhook_secret)
15 |
16 | true
17 | end
18 |
19 | def check_signature(raw_signature, timestamp, payload, webhook_secret)
20 | raise Urlbox::InvalidHeaderSignatureError, 'Invalid signature' unless SIGNATURE_REGEX.match?(raw_signature)
21 |
22 | signature_webhook = raw_signature.split('=')[1]
23 | timestamp_parsed = timestamp.split('=')[1]
24 | signature_generated =
25 | OpenSSL::HMAC.hexdigest('sha256',
26 | webhook_secret.encode('UTF-8'),
27 | "#{timestamp_parsed}.#{JSON.dump(payload).encode('UTF-8')}")
28 |
29 | raise Urlbox::InvalidHeaderSignatureError, 'Invalid signature' unless signature_generated == signature_webhook
30 | end
31 |
32 | def check_timestamp(raw_timestamp)
33 | raise Urlbox::InvalidHeaderSignatureError, 'Invalid timestamp' unless TIMESTAMP_REGEX.match?(raw_timestamp)
34 |
35 | timestamp = (raw_timestamp.split('=')[1]).to_i
36 |
37 | check_webhook_creation_time(timestamp)
38 | end
39 |
40 | def check_webhook_creation_time(header_timestamp)
41 | current_timestamp = Time.now.to_i
42 | webhook_posted = current_timestamp - header_timestamp
43 | webhook_posted_minutes_ago = webhook_posted / 60
44 |
45 | if webhook_posted_minutes_ago > WEBHOOK_AGE_MAX_MINUTES
46 | raise Urlbox::InvalidHeaderSignatureError, 'Invalid timestamp'
47 | end
48 | end
49 | end
50 |
51 | private_class_method :check_signature, :check_timestamp, :check_webhook_creation_time
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/test/test_urlbox_webhook_validator.rb:
--------------------------------------------------------------------------------
1 | require 'climate_control'
2 | require 'minitest/autorun'
3 | require 'urlbox/webhook_validator'
4 | require 'webmock/minitest'
5 |
6 | module Urlbox
7 | class WebhookValidatorTest < Minitest::Test
8 | # helper functions
9 |
10 | def header_signature
11 | "t=#{timestamp_one_minute_ago},sha256=1e1b3c7f6b5f60f7b44ed1a85e653769ecf0c41ec5c7e8c131fc1a20357cc2b1"
12 | end
13 |
14 | def timestamp_one_minute_ago
15 | (Time.now - 60).to_i
16 | end
17 |
18 | def payload
19 | {
20 | "event": "render.succeeded",
21 | "renderId": "794383cd-b09e-4aef-a12b-fadf8aad9d63",
22 | "result": {
23 | "renderUrl": "https://renders.urlbox.io/urlbox1/renders/61431b47b8538a00086c29dd/2021/11/24/bee42850-bab6-43c6-bd9d-e614581d31b4.png"
24 | },
25 | "meta": {
26 | "startTime": "2021-11-24T16:49:48.307Z",
27 | "endTime": "2021-11-24T16:49:53.659Z"
28 | }
29 | }
30 | end
31 |
32 | def webhook_secret
33 | "WEBHOOK_SECRET"
34 | end
35 | # helper functions - end
36 |
37 | def test_call_valid_webhook
38 | # Dynamically generate header signature to make the crypto comparision pass
39 | payload_json_string = JSON.dump(payload)
40 |
41 | signature_generated =
42 | OpenSSL::HMAC.hexdigest('sha256',
43 | webhook_secret.encode('UTF-8'),
44 | "#{timestamp_one_minute_ago}.#{payload_json_string.encode('UTF-8')}")
45 |
46 | header_signature = "t=#{timestamp_one_minute_ago},sha256=#{signature_generated}"
47 |
48 | assert Urlbox::WebhookValidator.call(header_signature, payload, webhook_secret)
49 | end
50 |
51 | def test_call_invalid_signature
52 | header_signature = "INVALID_SIGNATURE"
53 |
54 | assert_raises Urlbox::InvalidHeaderSignatureError do
55 | Urlbox::WebhookValidator.call(header_signature, payload, webhook_secret)
56 | end
57 | end
58 |
59 | def test_call_invalid_hash_mismatch
60 | header_signature = "t=#{timestamp_one_minute_ago},sha256=930ee08957512f247e289703ac951fc60da1e2d12919bfd518d90513b0687ee0"
61 |
62 | e = assert_raises Urlbox::InvalidHeaderSignatureError do
63 | Urlbox::WebhookValidator.call(header_signature, payload, webhook_secret)
64 | end
65 |
66 | assert_equal "Invalid signature", e.message
67 | end
68 |
69 | def test_call_invalid_hash_regex_catch
70 | header_signature = "t=#{timestamp_one_minute_ago},sha256=invalid_hash_regex"
71 |
72 | e = assert_raises Urlbox::InvalidHeaderSignatureError do
73 | Urlbox::WebhookValidator.call(header_signature, payload, webhook_secret)
74 | end
75 |
76 | assert_equal "Invalid signature", e.message
77 | end
78 |
79 | def test_call_invalid_timestamp
80 | header_signature = "t={invalid_timestamp},sha256=930ee08957512f247e289703ac951fc60da1e2d12919bfd518d90513b0687ee0"
81 |
82 | e = assert_raises Urlbox::InvalidHeaderSignatureError do
83 | Urlbox::WebhookValidator.call(header_signature, payload, webhook_secret)
84 | end
85 |
86 | assert_equal "Invalid timestamp", e.message
87 | end
88 |
89 | def test_call_invalid_timestamp_timing_attack
90 | timestamp_ten_minute_ago = (Time.now - 600).to_i
91 | header_signature = "t=#{timestamp_ten_minute_ago},sha256=930ee08957512f247e289703ac951fc60da1e2d12919bfd518d90513b0687ee0"
92 |
93 | e = assert_raises Urlbox::InvalidHeaderSignatureError do
94 | Urlbox::WebhookValidator.call(header_signature, payload, webhook_secret)
95 | end
96 |
97 | assert_equal "Invalid timestamp", e.message
98 | end
99 | end
100 | end
101 |
--------------------------------------------------------------------------------
/lib/urlbox/client.rb:
--------------------------------------------------------------------------------
1 | require 'http'
2 | require 'urlbox/errors'
3 |
4 | module Urlbox
5 | class Client
6 | BASE_API_URL = 'https://api.urlbox.io/v1/'.freeze
7 | POST_END_POINT = 'render'.freeze
8 |
9 | def initialize(api_key: nil, api_secret: nil, api_host_name: nil)
10 | if api_key.nil? && ENV['URLBOX_API_KEY'].nil?
11 | raise Urlbox::Error, "Missing api_key or ENV['URLBOX_API_KEY'] not set"
12 | end
13 |
14 | @api_key = api_key || ENV['URLBOX_API_KEY']
15 | @api_secret = api_secret || ENV['URLBOX_API_SECRET']
16 | @base_api_url = init_base_api_url(api_host_name)
17 | end
18 |
19 | def get(options)
20 | HTTP.timeout(100).follow.get(generate_url(options))
21 | end
22 |
23 | def delete(options)
24 | processed_options, format = process_options(options)
25 | HTTP.delete("#{@base_api_url}#{@api_key}/#{format}?#{processed_options}")
26 | end
27 |
28 | def head(options)
29 | processed_options, format = process_options(options)
30 | HTTP.timeout(100)
31 | .follow
32 | .head("#{@base_api_url}#{@api_key}/#{format}?#{processed_options}")
33 | end
34 |
35 | def post(options)
36 | raise Urlbox::Error, Urlbox::Error.missing_api_secret_error_message if @api_secret.nil?
37 |
38 | unless options.key?(:webhook_url)
39 | warn('webhook_url not supplied, you will need to poll the statusUrl in order to get your result')
40 | end
41 |
42 | processed_options, _format = process_options_post_request(options)
43 | HTTP.timeout(5)
44 | .headers('Content-Type': 'application/json', 'Authorization': "Bearer #{@api_secret}")
45 | .post("#{@base_api_url}#{POST_END_POINT}", json: processed_options)
46 | end
47 |
48 | def generate_url(options)
49 | processed_options, format = process_options(options)
50 |
51 | if @api_secret
52 | "#{@base_api_url}" \
53 | "#{@api_key}/#{token(processed_options)}/#{format}" \
54 | "?#{processed_options}"
55 | else
56 | "#{@base_api_url}" \
57 | "#{@api_key}/#{format}" \
58 | "?#{processed_options}"
59 | end
60 | end
61 |
62 | # class methods to allow easy env var based usage
63 | class << self
64 | %i[delete head get post generate_url].each do |method|
65 | define_method(method) do |options|
66 | new.send(method, options)
67 | end
68 | end
69 | end
70 |
71 | private
72 |
73 | def init_base_api_url(api_host_name)
74 | if api_host_name
75 | "https://#{api_host_name}/"
76 | elsif ENV['URLBOX_API_HOST_NAME']
77 | ENV['URLBOX_API_HOST_NAME']
78 | else
79 | BASE_API_URL
80 | end
81 | end
82 |
83 | def prepend_schema(url)
84 | url.start_with?('http') ? url : "http://#{url}"
85 | end
86 |
87 | def process_options(options, url_encode_options: true)
88 | processed_options = options.transform_keys(&:to_sym)
89 |
90 | raise_key_error_if_missing_required_keys(processed_options)
91 |
92 | processed_options[:url] = process_url(processed_options[:url]) if processed_options[:url]
93 | processed_options[:format] = processed_options.fetch(:format, 'png')
94 |
95 | if url_encode_options
96 | [URI.encode_www_form(processed_options), processed_options[:format]]
97 | else
98 | [processed_options, processed_options[:format]]
99 | end
100 | end
101 |
102 | def process_options_post_request(options)
103 | process_options(options, url_encode_options: false)
104 | end
105 |
106 | def process_url(url)
107 | url_parsed = prepend_schema(url.strip)
108 |
109 | raise Urlbox::Error, "Invalid URL: #{url_parsed}" unless valid_url?(url_parsed)
110 |
111 | url_parsed
112 | end
113 |
114 | def raise_key_error_if_missing_required_keys(options)
115 | return unless options[:url].nil? && options[:html].nil?
116 |
117 | raise Urlbox::Error, 'Missing url or html entry in options'
118 | end
119 |
120 | def token(url_encoded_options)
121 | OpenSSL::HMAC.hexdigest('sha1', @api_secret.encode('UTF-8'), url_encoded_options.encode('UTF-8'))
122 | end
123 |
124 | def valid_url?(url)
125 | parsed_url = URI.parse(url)
126 | !parsed_url.host.nil? && parsed_url.host.include?('.')
127 | end
128 | end
129 | end
130 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | # Urlbox Ruby Library
3 | 
4 | 
5 |
6 | The Urlbox Ruby gem provides easy access to the Urlbox website screenshot API from your Ruby/Rails application.
7 |
8 | Now there's no need to muck around with http clients, etc...
9 |
10 | Just initialise the Urlbox::Client and make a screenshot of a URL in seconds.
11 |
12 |
13 | ## Documentation
14 |
15 | See the Urlbox API Docs.
16 |
17 | ## Requirements
18 |
19 | Ruby 2.5 and above.
20 |
21 | ## Installation
22 |
23 | Add this line to your application's Gemfile:
24 |
25 | ```ruby
26 | gem 'urlbox'
27 | ```
28 |
29 | And then run:
30 | ```
31 | $ bundle install
32 | ```
33 | Or just...
34 | ```
35 | $ gem install urlbox
36 | ```
37 |
38 | ## Usage
39 |
40 | First, grab your Urlbox API key and API secret* found in your Urlbox Dashboard.
41 |
42 | *\* Requests will be automatically authenticated when you supply your API secret.*
43 |
44 | ### Quick Start: Generate a Screenshot URL
45 | For use directly in HTML templates, the browser etc.
46 |
47 | ```ruby
48 | require 'urlbox/client'
49 |
50 | # Initialise the UrlboxClient
51 | urlbox_client = Urlbox::Client.new(api_key: 'YOUR_API_KEY', api_secret: 'YOUR_API_SECRET')
52 |
53 | # Generate a screenshot url
54 | screenshot_url = urlbox_client.generate_url({url: 'http://example.com/'})
55 |
56 | ```
57 |
58 | In your erb/html template, use the screenshot_url generated above:
59 | ```html
60 | <%= image_tag screenshot_url %>
61 | ```
62 |
63 | ### Quick Start: Quickly Get a Screenshot of a URL
64 | ```ruby
65 | require 'urlbox/client'
66 |
67 | urlbox_client = Urlbox::Client.new(api_key: 'YOUR_API_KEY', api_secret: 'YOUR_API_SECRET')
68 |
69 | # Make a request to the UrlBox API
70 | response = urlbox_client.get({url: 'http://example.com/'})
71 |
72 | # Save your screenshot image to screenshot.png:
73 | File.write('screenshot.png', response.to_s)
74 | ```
75 |
76 | All UrlboxClient methods require at least one argument: a hash that *must include either a "url", or "html" entry*, which the Urlbox API will render as a screenshot.
77 |
78 | Additional options in the dictionary include:
79 |
80 | "format" can be either: png, jpg or jpeg, avif, webp ,pdf, svg, html *(defaults to png if not provided).*
81 |
82 | "full_page", "width", and many more.
83 | See all available options here: https://urlbox.com/docs/options
84 |
85 | eg:
86 | ```ruby
87 | {url: 'http://example.com/', full_page: true, width: 300}
88 | ```
89 |
90 |
91 | ### A More Extensive Get Request
92 | ```ruby
93 | options = {
94 | url: "https://www.independent.co.uk/arts-entertainment/tv/news/squid-game-real-youtube-mrbeast-b1964007.html",
95 | format: 'jpg',
96 | full_page: false,
97 | hide_cookie_banners: true,
98 | block_ads: true
99 | }
100 |
101 | response = urlbox_client.get(options)
102 |
103 | # The Urlbox API will return binary data as the response with the
104 | # Content-Type header set to the relevant mime-type for the format requested.
105 | # For example, if you requested jpg format, the Content-Type will be image/jpeg
106 | # and response body will be the actual jpg binary data.
107 |
108 | response.to_s # Your screenshot as binary image data which looks like 👇
109 | ```
110 | 
111 |
112 |
113 | ## Other Methods/Requests
114 | The UrlboxClient has the following public methods:
115 |
116 | ### get(options)
117 | *(as detailed in the above examples)*
118 | Makes a GET request to the Urlbox API to create a screenshot for the url or html passed in the options dictionary.
119 |
120 | Example request:
121 | ```ruby
122 | response = urlbox_client.get({url: 'http://example.com/'})
123 | response.to_s # Your screenshot 🎉
124 | ```
125 |
126 | ### delete(options)
127 | Removes a previously created screenshot from the cache.
128 |
129 | Example request:
130 | ```ruby
131 | urlbox_client.delete({url: 'http://example.com/'})
132 | ```
133 | ### head(options)
134 | If you just want to get the response status/headers without pulling down the full response body.
135 |
136 | Example request:
137 | ```ruby
138 | response = urlbox_client.head({url: 'http://example.com/'})
139 |
140 | puts(response.headers)
141 |
142 | ```
143 |
144 | Example response headers:
145 |
146 | ```json
147 | {
148 | "Date":"Fri, 26 Nov 2021 16:22:56 GMT",
149 | "Content-Type":"image/png",
150 | "Content-Length":"1268491",
151 | "Connection":"keep-alive",
152 | "Cache-Control":"public, max-age=2592000",
153 | "Expires":"Sun, 26 Dec 2021 16:16:09 GMT",
154 | "Last-Modified":"Fri, 26 Nov 2021 16:14:56 GMT",
155 | "X-Renders-Used":"60",
156 | "X-Renders-Reset":"Sun Dec 05 2021 09:58:00 GMT+0000 (Coordinated Universal Time)",
157 | "X-Renders-Allowed":"22000"
158 | }
159 | ```
160 | You can use these headers to check how many renders you have used or your current rate limiting status, etc.
161 |
162 | ### post(options)
163 | Uses Urlbox's webhook functionality to initialise a render of a screenshot. You will need to provide a *"webhook_url"* entry in the options which Urlbox will post back to when the rendering of the screenshot is complete.
164 |
165 | Example request:
166 | ```ruby
167 | urlbox_client.post({url: "http://twitter.com/", webhook_url: "http://yoursite.com/webhook"})
168 | ```
169 | Give it a couple of seconds, and you should receive, posted to the webhook_url specified in your request above, a post request with a JSON body similar to:
170 | ```json
171 | {
172 | "event": "render.succeeded",
173 | "renderId": "2cf5ffe2-7736-4d41-8c30-f13e16d35248",
174 | "result": {
175 | "renderUrl": "https://renders.urlbox.io/urlbox1/renders/61431b47b8538a00086c29dd/2021/11/25/e2dcec18-8353-435c-ba17-b549c849eec5.png"
176 | },
177 | "meta": {
178 | "startTime": "2021-11-25T16:32:32.453Z",
179 | "endTime": "2021-11-25T16:32:38.719Z"
180 | }
181 | }
182 | ```
183 | You can then parse the renderUrl value to access the your screenshot.
184 |
185 |
186 | ## Secure Webhook Posts
187 | The Urlbox API post to your webhook endpoint will include a header that you can use to ensure this is a genuine request from the Urlbox API, and not a malicious actor.
188 |
189 | Using your http client of choice, access the *x-urlbox-signature* header. Its value will be something similar to:
190 |
191 | `t=1637857959,sha256=1d721f99aa03122d494f8b49f201fdf806efaec609c614f0a0ec7b394f1d403a`
192 |
193 | Use the *webhook_validator* helper function that is included, for no extra charge, in the urlbox package to verify that the webhook post is indeed a genuine request from the Urlbox API. Like so:
194 |
195 | ```ruby
196 | require 'urlbox/webhook_validator'
197 |
198 | # extracted from the x-urlbox-signature header
199 | header_signature = "t=1637857959,sha256=1d721f..."
200 |
201 | # the raw JSON payload from the webhook request body
202 | payload = {
203 | "event": "render.succeeded",
204 | "renderId": "794383cd-b09e-4aef-a12b-fadf8aad9d63",
205 | "result": {
206 | "renderUrl": "https://renders.urlbox.io/urlbox1/renders/foo.png"
207 | },
208 | "meta": {
209 | "startTime": "2021-11-24T16:49:48.307Z",
210 | "endTime": "2021-11-24T16:49:53.659Z",
211 | },
212 | }
213 |
214 | # Your webhook secret - coming soon.
215 | # NB: This is NOT your api_secret, that's different.
216 | webhook_secret = "YOUR_WEBHOOK_SECRET"
217 |
218 | # This will either return true (if the signature is genuinely from Urlbox)
219 | # or it will raise a InvalidHeaderSignatureError (if the signature is not from Urlbox)
220 | Urlbox::WebhookValidator.call(header_signature, payload, webhook_secret)
221 | ```
222 |
223 | ## Using Env Vars?
224 |
225 | If you are using env vars, in your .env file, set:
226 | ```yaml
227 | URLBOX_API_KEY: YOUR_URLBOX_API_KEY
228 | URLBOX_API_SECRET: YOUR_URLBOX_API_SECRET
229 | URLBOX_API_HOST_NAME: YOUR_URLBOX_API_HOST_NAME # (optional, advanced usage)
230 | ```
231 |
232 | Then the Urlbox::Client will pick these up and you can use all the above Urlbox::Client class methods directly, without having to initialise the Urlbox::Client.
233 | Eg:
234 |
235 | ```ruby
236 | require 'urlbox/client'
237 |
238 | screenshot_url = Urlbox::Client.generate_url({url: "http://example.com/"})
239 |
240 | Urlbox::Client.get(...)
241 | Urlbox::Client.head(...)
242 | # etc
243 |
244 | ```
245 | ## Feedback
246 |
247 |
248 | Feel free to contact us if you spot a bug or have any suggestions at: support`[at]`urlbox.com.
249 |
--------------------------------------------------------------------------------
/test/test_urlbox_client.rb:
--------------------------------------------------------------------------------
1 | require 'climate_control'
2 | require 'minitest/autorun'
3 | require 'urlbox/client'
4 | require 'webmock/minitest'
5 |
6 | module Urlbox
7 | class ClientTest < Minitest::Test
8 | # test_init
9 | def test_no_api_key_provided_and_env_var_not_set
10 | e = assert_raises Urlbox::Error do
11 | Urlbox::Client.new
12 | end
13 |
14 | assert_equal e.message, "Missing api_key or ENV['URLBOX_API_KEY'] not set"
15 | end
16 |
17 | def test_no_api_key_provided_but_env_var_is_set
18 | env_var_api_key = 'ENV_VAR_KEY'
19 |
20 | ClimateControl.modify URLBOX_API_KEY: env_var_api_key do
21 | urlbox_client = Urlbox::Client.new
22 |
23 | # It doesn't throw an error and sets the api_key with the env var value
24 | assert_equal urlbox_client.instance_variable_get(:@api_key), env_var_api_key
25 | end
26 | end
27 |
28 | def test_api_key_provided_and_env_var_is_set
29 | env_var_api_key = 'ENV_VAR_KEY'
30 | param_api_key = 'PARAM_KEY'
31 |
32 | ClimateControl.modify URLBOX_API_KEY: env_var_api_key do
33 | urlbox_client = Urlbox::Client.new(api_key: param_api_key)
34 |
35 | # It sets the api_key with the param, not the env var, value
36 | assert_equal urlbox_client.instance_variable_get(:@api_key), param_api_key
37 | end
38 | end
39 |
40 | def test_no_api_secret_provided_but_env_var_is_set
41 | env_var_api_secret = 'ENV_VAR_SECRET'
42 | param_api_key = 'PARAM_KEY'
43 |
44 | ClimateControl.modify URLBOX_API_SECRET: env_var_api_secret do
45 | urlbox_client = Urlbox::Client.new(api_key: param_api_key)
46 |
47 | assert_equal urlbox_client.instance_variable_get(:@api_secret), env_var_api_secret
48 | end
49 | end
50 |
51 | def test_api_secret_provided_and_env_var_is_set
52 | env_var_api_secret = 'ENV_VAR_SECRET'
53 | param_api_secret = 'PARAM_SECRET'
54 | param_api_key = 'PARAM_KEY'
55 |
56 | ClimateControl.modify URLBOX_API_SECRET: env_var_api_secret do
57 | urlbox_client = Urlbox::Client.new(api_key: param_api_key, api_secret: param_api_secret)
58 |
59 | # It sets the api_secret with the param, not the env var, value
60 | assert_equal urlbox_client.instance_variable_get(:@api_secret), param_api_secret
61 | end
62 | end
63 |
64 | def test_api_host_name_provided
65 | param_api_key = 'PARAM_KEY'
66 | api_host_name = ['api-eu.urlbox.io', 'api-direct.urlbox.io'].sample
67 |
68 | urlbox_client = Urlbox::Client.new(api_key: param_api_key, api_host_name: api_host_name)
69 |
70 | # Use the api_host_name
71 | assert_equal urlbox_client.instance_variable_get(:@base_api_url), "https://#{api_host_name}/"
72 | end
73 |
74 | def test_no_api_host_name_provided
75 | param_api_key = 'PARAM_KEY'
76 |
77 | urlbox_client = Urlbox::Client.new(api_key: param_api_key)
78 |
79 | # Use the default BASE_API_URL
80 | assert_equal urlbox_client.instance_variable_get(:@base_api_url), Urlbox::Client::BASE_API_URL
81 | end
82 |
83 | def test_no_api_host_name_provided_but_env_var_is_set
84 | param_api_key = 'PARAM_KEY'
85 | env_var_api_host_name = 'ENV_VAR_HOST_NAME'
86 |
87 | ClimateControl.modify URLBOX_API_HOST_NAME: env_var_api_host_name do
88 | urlbox_client = Urlbox::Client.new(api_key: param_api_key)
89 |
90 | assert_equal env_var_api_host_name, urlbox_client.instance_variable_get(:@base_api_url)
91 | end
92 | end
93 |
94 | # Test get
95 | def test_successful_get_request
96 | param_api_key = 'KEY'
97 | options = { url: 'https://www.example.com' }
98 |
99 | stub_request(:get, "https://api.urlbox.io/v1/#{param_api_key}/png?format=png&url=https://www.example.com")
100 | .to_return(status: 200, body: '', headers: { 'content-type': 'image/png' })
101 |
102 | urlbox_client = Urlbox::Client.new(api_key: param_api_key)
103 |
104 | response = urlbox_client.get(options)
105 |
106 | assert response.status == 200
107 | assert response.headers['Content-Type'].include?('png')
108 | end
109 |
110 | def test_successful_get_request_no_schema_url
111 | param_api_key = 'KEY'
112 | options = { url: 'www.example.com' }
113 |
114 | stub_request(:get, "https://api.urlbox.io/v1/#{param_api_key}/png?format=png&url=http://www.example.com")
115 | .to_return(status: 200, body: '', headers: {})
116 |
117 | urlbox_client = Urlbox::Client.new(api_key: param_api_key)
118 |
119 | response = urlbox_client.get(options)
120 |
121 | assert response.status == 200
122 | end
123 |
124 | def test_unsuccessful_get_invalid_url
125 | param_api_key = 'KEY'
126 | options = { url: 'FOO' }
127 |
128 | urlbox_client = Urlbox::Client.new(api_key: param_api_key)
129 |
130 | e = assert_raises Urlbox::Error do
131 | urlbox_client.get(options)
132 | end
133 |
134 | assert_equal e.message, 'Invalid URL: http://FOO'
135 | end
136 |
137 | def test_unsuccessful_get_missing_url_or_html_entry_in_options
138 | param_api_key = 'KEY'
139 | options = { format: 'png' }
140 |
141 | urlbox_client = Urlbox::Client.new(api_key: param_api_key)
142 |
143 | e = assert_raises Urlbox::Error do
144 | urlbox_client.get(options)
145 | end
146 |
147 | assert_equal e.message, 'Missing url or html entry in options'
148 | end
149 |
150 | def test_successful_get_request_authenticated
151 | api_key = 'KEY'
152 | api_secret = 'SECRET'
153 | options = { url: 'https://www.example.com', format: 'png' }
154 | url_encoded_options = URI.encode_www_form(options)
155 | token = OpenSSL::HMAC.hexdigest('sha1', api_secret.encode('UTF-8'), url_encoded_options.encode('UTF-8'))
156 |
157 | stub_request(:get, "https://api.urlbox.io/v1/KEY/#{token}/png?format=png&url=https://www.example.com")
158 | .to_return(status: 200, body: '', headers: { 'content-type': 'image/png' })
159 |
160 | urlbox_client = Urlbox::Client.new(api_key: api_key, api_secret: api_secret)
161 |
162 | response = urlbox_client.get(options)
163 |
164 | assert response.status == 200
165 | assert response.headers['Content-Type'].include?('png')
166 | end
167 |
168 | def test_successful_get_request_options_with_string_keys
169 | param_api_key = 'KEY'
170 | options = { 'url' => 'https://www.example.com' }
171 |
172 | stub_request(:get, "https://api.urlbox.io/v1/#{param_api_key}/png?format=png&url=https://www.example.com")
173 | .to_return(status: 200, body: '', headers: { 'content-type': 'image/png' })
174 |
175 | urlbox_client = Urlbox::Client.new(api_key: param_api_key)
176 |
177 | response = urlbox_client.get(options)
178 |
179 | assert response.status == 200
180 | assert response.headers['Content-Type'].include?('png')
181 | end
182 |
183 | def test_get_with_header_array_in_options
184 | api_key = 'KEY'
185 | options = {
186 | url: 'https://www.example.com',
187 | header: ["x-my-first-header=somevalue", "x-my-second-header=someothervalue"]
188 | }
189 |
190 | urlbox_client = Urlbox::Client.new(api_key: api_key)
191 |
192 | stub_request(:get, "https://api.urlbox.io/v1/KEY/png?format=png&header=x-my-second-header=someothervalue&url=https://www.example.com")
193 | .to_return(status: 200, body: "", headers: {})
194 |
195 | response = urlbox_client.get(options)
196 |
197 | assert response.status == 200
198 | end
199 |
200 | # test delete
201 | def test_delete_request
202 | api_key = 'KEY'
203 | options = { url: 'https://www.example.com' }
204 |
205 | stub_request(:delete, 'https://api.urlbox.io/v1/KEY/png?format=png&url=https://www.example.com')
206 | .to_return(status: 200, body: '', headers: {})
207 |
208 | urlbox_client = Urlbox::Client.new(api_key: api_key)
209 |
210 | response = urlbox_client.delete(options)
211 |
212 | assert response.status == 200
213 | end
214 |
215 | # test head
216 | def test_head_request
217 | api_key = 'KEY'
218 | format = %w[png jpg jpeg avif webp pdf svg html].sample
219 | url = 'https://www.example.com'
220 |
221 | options = {
222 | url: url,
223 | format: format,
224 | full_page: [true, false].sample,
225 | width: [500, 700].sample
226 | }
227 |
228 | stub_request(:head, "https://api.urlbox.io/v1/#{api_key}/#{format}?format=#{format}&full_page=#{options[:full_page]}&url=#{url}&width=#{options[:width]}")
229 | .to_return(status: 200, body: '', headers: {})
230 |
231 | urlbox_client = Urlbox::Client.new(api_key: api_key)
232 |
233 | response = urlbox_client.head(options)
234 |
235 | assert response.status == 200
236 | end
237 |
238 | # test post
239 | def test_post_request_successful
240 | api_key = 'KEY'
241 | api_secret = 'SECRET'
242 | options = {
243 | url: 'https://www.example.com',
244 | webhook_url: 'https://www.example.com/webhook'
245 | }
246 |
247 | stub_request(:post, 'https://api.urlbox.io/v1/render')
248 | .with(
249 | body: "{\"url\":\"https://www.example.com\",\"webhook_url\":\"https://www.example.com/webhook\",\"format\":\"png\"}",
250 | headers: {
251 | 'Authorization' => 'Bearer SECRET',
252 | 'Content-Type' => 'application/json'
253 | }
254 | ).to_return(status: 201)
255 |
256 | urlbox_client = Urlbox::Client.new(api_key: api_key, api_secret: api_secret)
257 |
258 | response = urlbox_client.post(options)
259 |
260 | assert response.status == 201
261 | end
262 |
263 | def test_post_request_successful_warning_missing_webhook_url
264 | api_key = 'KEY'
265 | api_secret = 'SECRET'
266 | options = { url: 'https://www.example.com' }
267 |
268 | stub_request(:post, 'https://api.urlbox.io/v1/render')
269 | .with(
270 | body: "{\"url\":\"https://www.example.com\",\"format\":\"png\"}",
271 | headers: {
272 | 'Authorization' => 'Bearer SECRET',
273 | 'Content-Type' => 'application/json'
274 | }
275 | ).to_return(status: 201)
276 |
277 | urlbox_client = Urlbox::Client.new(api_key: api_key, api_secret: api_secret)
278 |
279 | response = urlbox_client.post(options)
280 |
281 | assert response.status == 201
282 | # Wasn't able to test the warning message
283 | end
284 |
285 | def test_post_request_unsuccessful_missing_api_secret
286 | api_key = 'KEY'
287 | options = {
288 | url: 'https://www.example.com',
289 | webhook_url: 'https://www.example.com/webhook'
290 | }
291 |
292 | urlbox_client = Urlbox::Client.new(api_key: api_key)
293 |
294 | e = assert_raises Urlbox::Error do
295 | urlbox_client.post(options)
296 | end
297 |
298 | assert e.message.include? 'Missing api_secret'
299 | end
300 |
301 | # test generate_url
302 | def test_generate_url_with_only_api_key
303 | api_key = 'KEY'
304 | options = { url: 'https://www.example.com', format: 'png' }
305 |
306 | urlbox_client = Urlbox::Client.new(api_key: api_key)
307 |
308 | urlbox_url = urlbox_client.generate_url(options)
309 |
310 | assert_equal 'https://api.urlbox.io/v1/KEY/png?url=https%3A%2F%2Fwww.example.com&format=png', urlbox_url
311 | end
312 |
313 | def test_generate_url_with_api_key_and_secret
314 | api_key = 'KEY'
315 | api_secret = 'SECRET'
316 | options = { url: 'https://www.example.com', format: 'png' }
317 | url_encoded_options = URI.encode_www_form(options)
318 | token = OpenSSL::HMAC.hexdigest('sha1', api_secret.encode('UTF-8'), url_encoded_options.encode('UTF-8'))
319 |
320 | urlbox_client = Urlbox::Client.new(api_key: api_key, api_secret: api_secret)
321 |
322 | urlbox_url = urlbox_client.generate_url(options)
323 |
324 | assert_equal "https://api.urlbox.io/v1/KEY/#{token}/png?url=https%3A%2F%2Fwww.example.com&format=png", urlbox_url
325 | end
326 |
327 | # test module like methods
328 | # get
329 | def test_successful_class_get_request
330 | env_var_api_key = 'ENV_VAR_KEY'
331 | options = { url: 'https://www.example.com' }
332 |
333 | ClimateControl.modify URLBOX_API_KEY: env_var_api_key do
334 | stub_request(:get, "https://api.urlbox.io/v1/#{env_var_api_key}/png?format=png&url=https://www.example.com")
335 | .to_return(status: 200, body: '', headers: { 'content-type': 'image/png' })
336 |
337 | response = Urlbox::Client.get(options)
338 |
339 | assert response.status == 200
340 | assert response.headers['Content-Type'].include?('png')
341 | end
342 | end
343 |
344 | # delete
345 | def test_successful_class_delete_request
346 | env_var_api_key = 'ENV_VAR_KEY'
347 | options = { url: 'https://www.example.com' }
348 |
349 | ClimateControl.modify URLBOX_API_KEY: env_var_api_key do
350 | stub_request(:delete, "https://api.urlbox.io/v1/#{env_var_api_key}/png?format=png&url=https://www.example.com")
351 | .to_return(status: 200, body: '', headers: { 'content-type': 'image/png' })
352 |
353 | response = Urlbox::Client.delete(options)
354 |
355 | assert response.status == 200
356 | assert response.headers['Content-Type'].include?('png')
357 | end
358 | end
359 |
360 | # head
361 | def test_successful_class_head_request
362 | env_var_api_key = 'ENV_VAR_KEY'
363 | options = { url: 'https://www.example.com' }
364 |
365 | ClimateControl.modify URLBOX_API_KEY: env_var_api_key do
366 | stub_request(:head, "https://api.urlbox.io/v1/#{env_var_api_key}/png?format=png&url=https://www.example.com")
367 | .to_return(status: 200, body: '', headers: { 'content-type': 'image/png' })
368 |
369 | response = Urlbox::Client.head(options)
370 |
371 | assert response.status == 200
372 | assert response.headers['Content-Type'].include?('png')
373 | end
374 | end
375 |
376 | # post
377 | def test_successful_class_post_request
378 | api_key = 'KEY'
379 | api_secret = 'SECRET'
380 | options = {
381 | url: 'https://www.example.com',
382 | webhook_url: 'https://www.example.com/webhook'
383 | }
384 |
385 | ClimateControl.modify URLBOX_API_KEY: api_key, URLBOX_API_SECRET: api_secret do
386 | stub_request(:post, 'https://api.urlbox.io/v1/render')
387 | .with(
388 | body: "{\"url\":\"https://www.example.com\",\"webhook_url\":\"https://www.example.com/webhook\",\"format\":\"png\"}",
389 | headers: {
390 | 'Authorization' => 'Bearer SECRET',
391 | 'Content-Type' => 'application/json'
392 | }
393 | ).to_return(status: 201)
394 |
395 | response = Urlbox::Client.post(options)
396 |
397 | assert response.status == 201
398 | end
399 | end
400 |
401 | # generate_url
402 | def test_successful_class_generate_url
403 | env_var_api_key = 'KEY'
404 | options = { url: 'https://www.example.com' }
405 |
406 | ClimateControl.modify URLBOX_API_KEY: env_var_api_key do
407 | urlbox_url = Urlbox::Client.generate_url(options)
408 |
409 | assert_equal 'https://api.urlbox.io/v1/KEY/png?url=https%3A%2F%2Fwww.example.com&format=png', urlbox_url
410 | end
411 | end
412 | end
413 | end
414 |
--------------------------------------------------------------------------------