loopstak-shopify-sinatra-app 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.
@@ -0,0 +1,3 @@
1
+ .env
2
+ *.sqlite3
3
+ .byebug_history
@@ -0,0 +1,28 @@
1
+ source 'https://rubygems.org'
2
+ ruby '2.5.3'
3
+
4
+ gem 'shopify-sinatra-app', path: '../'
5
+ gem 'sinatra-activerecord'
6
+ gem 'rack-flash3', require: 'rack-flash'
7
+
8
+ group :production do
9
+ gem 'pg'
10
+ end
11
+
12
+ group :development, :test do
13
+ gem 'sqlite3'
14
+ gem 'byebug'
15
+ end
16
+
17
+ group :development do
18
+ gem 'rake'
19
+ gem 'foreman'
20
+ gem 'dotenv'
21
+ end
22
+
23
+ group :test do
24
+ gem 'mocha', require: false
25
+ gem 'minitest'
26
+ gem 'rack-test'
27
+ gem 'fakeweb'
28
+ end
@@ -0,0 +1 @@
1
+ web: bundle exec rackup config.ru -p $PORT
@@ -0,0 +1,4 @@
1
+ New Shopify-Sinatra-App
2
+ =======================
3
+
4
+ Fill me in!
@@ -0,0 +1,37 @@
1
+ require 'sinatra/activerecord/rake'
2
+ require 'rake/testtask'
3
+ require './src/app'
4
+
5
+ task :creds2heroku do
6
+ Bundler.with_clean_env do
7
+ File.readlines('.env').each do |var|
8
+ pipe = IO.popen("heroku config:set #{var}")
9
+ while (line = pipe.gets)
10
+ print line
11
+ end
12
+ end
13
+ end
14
+ end
15
+
16
+ task :deploy2heroku do
17
+ pipe = IO.popen('git push heroku master --force')
18
+ while (line = pipe.gets)
19
+ print line
20
+ end
21
+ end
22
+
23
+ namespace :test do
24
+ task :prepare do
25
+ `RACK_ENV=test rake db:create`
26
+ `RACK_ENV=test rake db:migrate`
27
+ `RACK_ENV=test SECRET=secret rake db:seed`
28
+ end
29
+ end
30
+
31
+ task :test do
32
+ Rake::TestTask.new do |t|
33
+ t.pattern = 'test/*_test.rb'
34
+ t.libs << 'test'
35
+ t.verbose = true
36
+ end
37
+ end
@@ -0,0 +1,7 @@
1
+ if Gem::Specification.find_all_by_name('dotenv').any?
2
+ require 'dotenv'
3
+ Dotenv.load
4
+ end
5
+
6
+ require './src/app'
7
+ SinatraApp.run!
@@ -0,0 +1,12 @@
1
+ development:
2
+ adapter: sqlite3
3
+ database: db/development.sqlite3
4
+ pool: 5
5
+
6
+ production:
7
+ url: <%= ENV['DATABASE_URL'] %>
8
+
9
+ test:
10
+ adapter: sqlite3
11
+ database: "db/test.sqlite3"
12
+ pool: 8
@@ -0,0 +1,12 @@
1
+ class CreateShops < ActiveRecord::Migration[5.1]
2
+ def self.up
3
+ create_table :shops do |t|
4
+ t.string :name
5
+ t.string :token_encrypted
6
+ end
7
+ end
8
+
9
+ def self.down
10
+ drop_table :shops
11
+ end
12
+ end
@@ -0,0 +1,9 @@
1
+ class AddIndexToShops < ActiveRecord::Migration[5.1]
2
+ def self.up
3
+ add_index :shops, :name
4
+ end
5
+
6
+ def self.down
7
+ remove_index :shops, :name
8
+ end
9
+ end
@@ -0,0 +1,21 @@
1
+ # This file is auto-generated from the current state of the database. Instead
2
+ # of editing this file, please use the migrations feature of Active Record to
3
+ # incrementally modify your database, and then regenerate this schema definition.
4
+ #
5
+ # Note that this schema.rb definition is the authoritative source for your
6
+ # database schema. If you need to create the application database on another
7
+ # system, you should be using db:schema:load, not running all the migrations
8
+ # from scratch. The latter is a flawed and unsustainable approach (the more migrations
9
+ # you'll amass, the slower it'll run and the greater likelihood for issues).
10
+ #
11
+ # It's strongly recommended that you check this file into your version control system.
12
+
13
+ ActiveRecord::Schema.define(version: 2014_04_14_042317) do
14
+
15
+ create_table "shops", force: :cascade do |t|
16
+ t.string "name"
17
+ t.string "token_encrypted"
18
+ t.index ["name"], name: "index_shops_on_name"
19
+ end
20
+
21
+ end
@@ -0,0 +1 @@
1
+ shop = Shop.create(name: 'testshop.myshopify.com', token: 'token')
Binary file
Binary file
@@ -0,0 +1,54 @@
1
+ require 'sinatra/shopify-sinatra-app'
2
+
3
+ class SinatraApp < Sinatra::Base
4
+ register Sinatra::Shopify
5
+
6
+ # set the scope that your app needs, read more here:
7
+ # http://docs.shopify.com/api/tutorials/oauth
8
+ set :scope, 'read_products, read_orders'
9
+
10
+ # Your App's Home page
11
+ # this is a simple example that fetches some products
12
+ # from Shopify and displays them inside your app
13
+ get '/' do
14
+ shopify_session do |shop_name|
15
+ @shop = ShopifyAPI::Shop.current
16
+ @products = ShopifyAPI::Product.find(:all, params: { limit: 10 })
17
+ erb :home
18
+ end
19
+ end
20
+
21
+ # this endpoint recieves the uninstall webhook
22
+ # and cleans up data, add to this endpoint as your app
23
+ # stores more data.
24
+ post '/uninstall' do
25
+ shopify_webhook do |shop_name, params|
26
+ Shop.find_by(name: shop_name).destroy
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ # This method gets called when your app is installed.
33
+ # setup any webhooks or services you need on Shopify
34
+ # inside here.
35
+ def after_shopify_auth
36
+ # shopify_session do
37
+ # create an uninstall webhook, this webhook gets sent
38
+ # when your app is uninstalled from a shop. It is good
39
+ # practice to clean up any data from a shop when they
40
+ # uninstall your app:
41
+
42
+ # uninstall_webhook = ShopifyAPI::Webhook.new(
43
+ # topic: 'app/uninstalled',
44
+ # address: "#{base_url}/uninstall",
45
+ # format: 'json'
46
+ # )
47
+ # begin
48
+ # uninstall_webhook.save!
49
+ # rescue => e
50
+ # raise unless uninstall_webhook.persisted?
51
+ # end
52
+ # end
53
+ end
54
+ end
@@ -0,0 +1,80 @@
1
+ require 'test_helper'
2
+ require './src/app'
3
+
4
+ class MockShop
5
+ def initialize(shop_name)
6
+ @shop_name = shop_name
7
+ end
8
+
9
+ def myshopify_domain
10
+ @shop_name
11
+ end
12
+ end
13
+
14
+ class AppTest < Minitest::Test
15
+ def app
16
+ SinatraApp
17
+ end
18
+
19
+ def setup
20
+ @shop_name = 'testshop.myshopify.com'
21
+ @shopify_shop = MockShop.new(@shop_name)
22
+ end
23
+
24
+ def test_root_with_session
25
+ set_session
26
+ fake 'https://testshop.myshopify.com/admin/shop.json', body: {myshopify_domain: @shop_name}.to_json
27
+ fake 'https://testshop.myshopify.com/admin/products.json?limit=10', body: '{}'
28
+ get '/'
29
+ assert last_response.ok?
30
+ end
31
+
32
+ def test_root_with_session_activates_api
33
+ set_session
34
+ SinatraApp.any_instance.expects(:activate_shopify_api).with(@shop_name, 'token')
35
+ ShopifyAPI::Shop.expects(:current).returns(@shopify_shop)
36
+ ShopifyAPI::Product.expects(:find).returns([])
37
+ get '/'
38
+ assert last_response.ok?
39
+ end
40
+
41
+ def test_root_without_session_redirects_to_install
42
+ get '/'
43
+ assert_equal 302, last_response.status
44
+ assert_equal 'http://example.org/install', last_response.location
45
+ end
46
+
47
+ def test_root_with_shop_redirects_to_auth
48
+ get '/?shop=othertestshop.myshopify.com'
49
+ assert_match '/auth/shopify?shop=othertestshop.myshopify.com', last_response.body
50
+ end
51
+
52
+ def test_root_with_session_and_new_shop_redirects_to_auth
53
+ set_session
54
+ get '/?shop=othertestshop.myshopify.com'
55
+ assert_match '/auth/shopify?shop=othertestshop.myshopify.com', last_response.body
56
+ end
57
+
58
+ def test_root_rescues_UnauthorizedAccess_clears_session_and_redirects
59
+ set_session
60
+ SinatraApp.any_instance.expects(:activate_shopify_api).with(@shop_name, 'token')
61
+ SinatraApp.any_instance.expects(:clear_session)
62
+ ShopifyAPI::Shop.expects(:current).raises(ActiveResource::UnauthorizedAccess.new('UnauthorizedAccess'))
63
+ get '/'
64
+ assert_equal 302, last_response.status
65
+ assert_equal 'http://example.org/', last_response.location
66
+ end
67
+
68
+ def test_uninstall_webhook_endpoint
69
+ SinatraApp.any_instance.expects(:verify_shopify_webhook).returns(true)
70
+ Shop.any_instance.expects(:destroy)
71
+ post '/uninstall', '{}', 'HTTP_X_SHOPIFY_SHOP_DOMAIN' => @shop_name
72
+ assert last_response.ok?
73
+ end
74
+
75
+ private
76
+
77
+ def set_session(shop = 'testshop.myshopify.com', token = 'token')
78
+ SinatraApp.any_instance.stubs(:session).returns(shopify: { shop: shop, token: token })
79
+ end
80
+ end
@@ -0,0 +1,31 @@
1
+ $VERBOSE = nil
2
+
3
+ ENV['RACK_ENV'] = 'test'
4
+ ENV['SECRET'] = 'secret'
5
+
6
+ require 'minitest/autorun'
7
+ require 'rack/test'
8
+ require 'mocha/setup'
9
+ require 'fakeweb'
10
+
11
+ FakeWeb.allow_net_connect = false
12
+
13
+ module Helpers
14
+ include Rack::Test::Methods
15
+
16
+ def load_fixture(name)
17
+ File.read("./test/fixtures/#{name}")
18
+ end
19
+
20
+ def fake(url, options = {})
21
+ method = options.delete(:method) || :get
22
+ body = options.delete(:body) || '{}'
23
+ format = options.delete(:format) || :json
24
+
25
+ FakeWeb.register_uri(method, url, { body: body, status: 200, content_type: "application/#{format}" }.merge(options))
26
+ end
27
+ end
28
+
29
+ class Minitest::Test
30
+ include Helpers
31
+ end
@@ -0,0 +1,11 @@
1
+ <script type="text/javascript">
2
+ ShopifyApp.ready(function(){
3
+ <% if flash[:notice] %>
4
+ ShopifyApp.flashNotice("<%= flash[:notice] %>");
5
+ <% end %>
6
+
7
+ <% if flash[:error] %>
8
+ ShopifyApp.flashError("<%= flash[:error] %>");
9
+ <% end %>
10
+ });
11
+ </script>
@@ -0,0 +1,7 @@
1
+ <script type="text/javascript">
2
+ ShopifyApp.ready(function(){
3
+ ShopifyApp.Bar.initialize({
4
+ icon: '<%= "#{base_url}/icon.png" %>'
5
+ });
6
+ });
7
+ </script>
@@ -0,0 +1,9 @@
1
+ <h3>Shopify Sinatra App</h3>
2
+ <p>Welcome!</p>
3
+
4
+ <h3>Products</h3>
5
+ <ul>
6
+ <% @products.each do |product| %>
7
+ <li><a href=<%="https://#{@shop.myshopify_domain}/admin/products/#{product.id}"%> target="_blank"> <%= product.id %> </a></li>
8
+ <% end %>
9
+ </ul>
@@ -0,0 +1,40 @@
1
+ <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css" rel="stylesheet" integrity="sha256-7s5uDGW3AHqw6xtJmNNtr+OBRJUlgkNJEo78P4b0yRw= sha512-nNo+yCHEyn0smMxSswnf/OnX6/KwJuZTlNZBjauKhTK0c+zT+q5JOCx0UFhXQ6rJR9jg6Es8gPuD2uZcYDLqSw==" crossorigin="anonymous">
2
+
3
+ <style>
4
+ body {
5
+ background: #fff url(<%="#{base_url}/legend.gif"%>) no-repeat fixed right 130px;
6
+ }
7
+
8
+ .form-large input {
9
+ font-size: 18px;
10
+ padding: 8px;
11
+ height: auto;
12
+ line-height: normal;
13
+ }
14
+
15
+ .form-wide {
16
+ width: 480px;
17
+ }
18
+ </style>
19
+
20
+ <body>
21
+ <div style="margin-top: 100px"></div>
22
+
23
+ <div class="container">
24
+ <h1>Shopify Sinatra App</h1>
25
+ <h2><small>This app requires you to login to start using it.</small></h2>
26
+ </div>
27
+
28
+ <div style="margin-top: 30px"></div>
29
+
30
+ <div class="container">
31
+ <form role="form" class="form-large" action="/login" method="post">
32
+ <div class="input-group form-wide">
33
+ <input class="form-control" type="url" name="shop" placeholder="Shop URL">
34
+ <span class="input-group-btn">
35
+ <input class="btn" type="submit" value="Install App" />
36
+ </span>
37
+ </div>
38
+ </form>
39
+ </div>
40
+ </body>
@@ -0,0 +1,22 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <script src="https://cdn.shopify.com/s/assets/external/app.js"></script>
5
+ <script type="text/javascript">
6
+ ShopifyApp.init({
7
+ apiKey: "<%= SinatraApp.settings.api_key %>",
8
+ shopOrigin: "<%= shop_origin %>",
9
+ debug: true
10
+ });
11
+ </script>
12
+ <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css" rel="stylesheet" integrity="sha256-7s5uDGW3AHqw6xtJmNNtr+OBRJUlgkNJEo78P4b0yRw= sha512-nNo+yCHEyn0smMxSswnf/OnX6/KwJuZTlNZBjauKhTK0c+zT+q5JOCx0UFhXQ6rJR9jg6Es8gPuD2uZcYDLqSw==" crossorigin="anonymous">
13
+ </head>
14
+
15
+ <body>
16
+ <div class="container">
17
+ <%= erb :'_top_bar', layout: false, locals: locals %>
18
+ <%= erb :'_flash_messages', layout: false, locals: locals %>
19
+ <%= yield %>
20
+ </div>
21
+ </body>
22
+ </html>
@@ -0,0 +1,267 @@
1
+ require 'sinatra/base'
2
+ require 'sinatra/activerecord'
3
+
4
+ require 'rack-flash'
5
+ require 'attr_encrypted'
6
+ require 'active_support/all'
7
+
8
+ require 'shopify_api'
9
+ require 'omniauth-shopify-oauth2'
10
+
11
+ module Sinatra
12
+ module Shopify
13
+ module Methods
14
+
15
+ # designed to be overriden
16
+ def after_shopify_auth
17
+ end
18
+
19
+ def logout
20
+ session.delete(:shopify)
21
+ session.clear
22
+ end
23
+
24
+ # for the esdk initializer
25
+ def shop_origin
26
+ "https://#{session[:shopify][:shop]}"
27
+ end
28
+
29
+ def shopify_session(&blk)
30
+ return_to = request.path
31
+ return_params = request.params
32
+
33
+ if no_session?
34
+ authenticate(return_to, return_params)
35
+ elsif different_shop?
36
+ logout
37
+ authenticate(return_to, return_params)
38
+ else
39
+ shop_name = session[:shopify][:shop]
40
+ token = session[:shopify][:token]
41
+ activate_shopify_api(shop_name, token)
42
+ yield shop_name
43
+ end
44
+ rescue ActiveResource::UnauthorizedAccess
45
+ clear_session shop_name
46
+ redirect request.path
47
+ end
48
+
49
+ def shopify_webhook(&blk)
50
+ return unless verify_shopify_webhook
51
+ shop_name = request.env['HTTP_X_SHOPIFY_SHOP_DOMAIN']
52
+ webhook_body = ActiveSupport::JSON.decode(request.body.read.to_s)
53
+ yield shop_name, webhook_body
54
+ status 200
55
+ end
56
+
57
+ private
58
+
59
+ def request_protocol
60
+ request.secure? ? 'https' : 'http'
61
+ end
62
+
63
+ def base_url
64
+ "#{request_protocol}://#{request.env['HTTP_HOST']}"
65
+ end
66
+
67
+ def no_session?
68
+ !session.key?(:shopify)
69
+ end
70
+
71
+ def different_shop?
72
+ params[:shop].present? && session[:shopify][:shop] != sanitize_shop_param(params)
73
+ end
74
+
75
+ def authenticate(return_to = '/', return_params = nil)
76
+ if shop_name = sanitized_shop_name
77
+ session[:return_params] = return_params if return_params
78
+ redirect_url = "/auth/shopify?shop=#{shop_name}&return_to=#{base_url}#{return_to}"
79
+ redirect_javascript redirect_url
80
+ else
81
+ redirect '/install'
82
+ end
83
+ end
84
+
85
+ def activate_shopify_api(shop_name, token)
86
+ api_session = ShopifyAPI::Session.new(domain: shop_name, token: token, api_version: ENV['SHOPIFY_API_VERSION'] || '2020-01')
87
+ ShopifyAPI::Base.activate_session(api_session)
88
+ end
89
+
90
+ def clear_session(shop_name)
91
+ logout
92
+ shop = Shop.find_by(name: shop_name)
93
+ shop.token = nil
94
+ shop.save
95
+ end
96
+
97
+ def redirect_javascript(url)
98
+ erb %(
99
+ <!DOCTYPE html>
100
+ <html lang="en">
101
+ <head>
102
+ <meta charset="utf-8" />
103
+ <base target="_top">
104
+ <title>Redirecting…</title>
105
+
106
+ <script type='text/javascript'>
107
+ // If the current window is the 'parent', change the URL by setting location.href
108
+ if (window.top == window.self) {
109
+ window.top.location.href = #{url.to_json};
110
+
111
+ // If the current window is the 'child', change the parent's URL with postMessage
112
+ } else {
113
+ message = JSON.stringify({
114
+ message: 'Shopify.API.remoteRedirect',
115
+ data: { location: window.location.origin + #{url.to_json} }
116
+ });
117
+ window.parent.postMessage(message, 'https://#{sanitized_shop_name}');
118
+ }
119
+ </script>
120
+ </head>
121
+ <body>
122
+ </body>
123
+ </html>
124
+ ), layout: false
125
+ end
126
+
127
+ def sanitized_shop_name
128
+ @sanitized_shop_name ||= sanitize_shop_param(params)
129
+ end
130
+
131
+ def sanitize_shop_param(params)
132
+ return unless params[:shop].present?
133
+ name = params[:shop].to_s.strip
134
+ name += '.myshopify.com' if !name.include?('myshopify.com') && !name.include?('.')
135
+ name.gsub!('https://', '')
136
+ name.gsub!('http://', '')
137
+
138
+ u = URI("http://#{name}")
139
+ u.host.ends_with?('.myshopify.com') ? u.host : nil
140
+ end
141
+
142
+ def verify_shopify_webhook
143
+ data = request.body.read.to_s
144
+ digest = OpenSSL::Digest.new('sha256')
145
+ calculated_hmac = Base64.encode64(OpenSSL::HMAC.digest(digest, settings.shared_secret, data)).strip
146
+ request.body.rewind
147
+
148
+ if calculated_hmac == request.env['HTTP_X_SHOPIFY_HMAC_SHA256']
149
+ true
150
+ else
151
+ puts 'Shopify Webhook verifictation failed!'
152
+ false
153
+ end
154
+ end
155
+ end
156
+
157
+ def self.registered(app)
158
+ app.helpers Shopify::Methods
159
+ app.register Sinatra::ActiveRecordExtension
160
+
161
+ app.set :database_file, File.expand_path('config/database.yml')
162
+ app.set :views, File.expand_path('views')
163
+ app.set :public_folder, File.expand_path('public')
164
+ app.set :erb, layout: :'layouts/application'
165
+ app.set :protection, except: :frame_options
166
+
167
+ app.enable :sessions
168
+ app.enable :inline_templates
169
+
170
+ app.set :scope, 'read_products, read_orders'
171
+
172
+ app.set :api_key, ENV['SHOPIFY_API_KEY']
173
+ app.set :shared_secret, ENV['SHOPIFY_SHARED_SECRET']
174
+ app.set :secret, ENV['SECRET']
175
+
176
+ app.use Rack::Flash, sweep: true
177
+ app.use Rack::MethodOverride
178
+ app.use Rack::Session::Cookie, key: 'rack.session',
179
+ path: '/',
180
+ secret: app.settings.secret,
181
+ expire_after: 60 * 30 # half an hour in seconds
182
+
183
+ app.use OmniAuth::Builder do
184
+ provider :shopify,
185
+ app.settings.api_key,
186
+ app.settings.shared_secret,
187
+
188
+ scope: app.settings.scope,
189
+
190
+ setup: lambda { |env|
191
+ params = Rack::Utils.parse_query(env['QUERY_STRING'])
192
+ site_url = "https://#{params['shop']}"
193
+ env['omniauth.strategy'].options[:client_options][:site] = site_url
194
+ }
195
+ end
196
+
197
+ ShopifyAPI::Session.setup(
198
+ api_key: app.settings.api_key,
199
+ secret: app.settings.shared_secret
200
+ )
201
+
202
+ app.get '/install' do
203
+ if params[:shop].present?
204
+ authenticate
205
+ else
206
+ erb :install, layout: false
207
+ end
208
+ end
209
+
210
+ app.post '/login' do
211
+ authenticate
212
+ end
213
+
214
+ app.get '/logout' do
215
+ logout
216
+ redirect '/install'
217
+ end
218
+
219
+ app.get '/auth/shopify/callback' do
220
+ shop_name = params['shop']
221
+ token = request.env['omniauth.auth']['credentials']['token']
222
+
223
+ shop = Shop.find_or_initialize_by(name: shop_name)
224
+ shop.token = token
225
+ shop.save!
226
+
227
+ session[:shopify] = {
228
+ shop: shop_name,
229
+ token: token
230
+ }
231
+
232
+ after_shopify_auth()
233
+
234
+ return_to = env['omniauth.params']['return_to']
235
+ return_params = session[:return_params]
236
+ session.delete(:return_params)
237
+
238
+ return_to += "?#{return_params.to_query}" if return_params.present?
239
+
240
+ redirect return_to
241
+ end
242
+
243
+ app.get '/auth/failure' do
244
+ erb "<h1>Authentication Failed:</h1>
245
+ <h3>message:<h3> <pre>#{params}</pre>", layout: false
246
+ end
247
+ end
248
+ end
249
+
250
+ register Shopify
251
+ end
252
+
253
+ class Shop < ActiveRecord::Base
254
+ def self.secret
255
+ @secret ||= ENV['SECRET']
256
+ end
257
+
258
+ attr_encrypted :token,
259
+ key: secret,
260
+ attribute: 'token_encrypted',
261
+ mode: :single_iv_and_salt,
262
+ algorithm: 'aes-256-cbc',
263
+ insecure_mode: true
264
+
265
+ validates_presence_of :name
266
+ validates_presence_of :token, on: :create
267
+ end