loopstak-shopify-sinatra-app 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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