├── 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 | ![image](https://user-images.githubusercontent.com/1453680/143582241-f44bd8c6-c242-48f4-8f9a-ed5507948588.png) 2 | # Urlbox Ruby Library 3 | ![Tests](https://github.com/urlbox/urlbox-python/actions/workflows/tests.yml/badge.svg) 4 | ![Linter](https://github.com/urlbox/urlbox-python/actions/workflows/linters.yml/badge.svg) 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 | ![image](https://user-images.githubusercontent.com/1453680/143479491-78d8edbc-dfdc-48e3-9ae0-3b59bcf98e2c.png) 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 | --------------------------------------------------------------------------------