spiffy_stores_app 8.2.6
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +13 -0
- data/.rubocop.yml +7 -0
- data/.travis.yml +9 -0
- data/CHANGELOG.md +3 -0
- data/Gemfile +6 -0
- data/README.md +346 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/spiffy_stores_app/redirect.js +19 -0
- data/app/controllers/spiffy_stores_app/authenticated_controller.rb +11 -0
- data/app/controllers/spiffy_stores_app/sessions_controller.rb +113 -0
- data/app/controllers/spiffy_stores_app/webhooks_controller.rb +36 -0
- data/app/views/spiffy_stores_app/sessions/new.html.erb +123 -0
- data/app/views/spiffy_stores_app/shared/redirect.html.erb +22 -0
- data/config/locales/de.yml +3 -0
- data/config/locales/en.yml +4 -0
- data/config/locales/es.yml +3 -0
- data/config/locales/fr.yml +4 -0
- data/config/locales/ja.yml +3 -0
- data/config/routes.rb +12 -0
- data/docs/Quickstart.md +76 -0
- data/images/app-proxy-screenshot.png +0 -0
- data/lib/generators/spiffy_stores_app/add_after_authenticate_job/add_after_authenticate_job_generator.rb +43 -0
- data/lib/generators/spiffy_stores_app/add_after_authenticate_job/templates/after_authenticate_job.rb +10 -0
- data/lib/generators/spiffy_stores_app/add_webhook/add_webhook_generator.rb +68 -0
- data/lib/generators/spiffy_stores_app/add_webhook/templates/webhook_job.rb +8 -0
- data/lib/generators/spiffy_stores_app/app_proxy_controller/app_proxy_controller_generator.rb +25 -0
- data/lib/generators/spiffy_stores_app/app_proxy_controller/templates/app_proxy_controller.rb +8 -0
- data/lib/generators/spiffy_stores_app/app_proxy_controller/templates/app_proxy_route.rb +10 -0
- data/lib/generators/spiffy_stores_app/app_proxy_controller/templates/index.html.erb +19 -0
- data/lib/generators/spiffy_stores_app/controllers/controllers_generator.rb +29 -0
- data/lib/generators/spiffy_stores_app/home_controller/home_controller_generator.rb +31 -0
- data/lib/generators/spiffy_stores_app/home_controller/templates/home_controller.rb +6 -0
- data/lib/generators/spiffy_stores_app/home_controller/templates/index.html.erb +21 -0
- data/lib/generators/spiffy_stores_app/home_controller/templates/spiffy_stores_app_ready_script.html.erb +7 -0
- data/lib/generators/spiffy_stores_app/install/install_generator.rb +58 -0
- data/lib/generators/spiffy_stores_app/install/templates/_flash_messages.html.erb +19 -0
- data/lib/generators/spiffy_stores_app/install/templates/embedded_app.html.erb +40 -0
- data/lib/generators/spiffy_stores_app/install/templates/omniauth.rb +2 -0
- data/lib/generators/spiffy_stores_app/install/templates/spiffy_provider.rb +11 -0
- data/lib/generators/spiffy_stores_app/install/templates/spiffy_stores_app.rb +9 -0
- data/lib/generators/spiffy_stores_app/routes/routes_generator.rb +31 -0
- data/lib/generators/spiffy_stores_app/routes/templates/routes.rb +11 -0
- data/lib/generators/spiffy_stores_app/shop_model/shop_model_generator.rb +38 -0
- data/lib/generators/spiffy_stores_app/shop_model/templates/db/migrate/create_shops.erb +15 -0
- data/lib/generators/spiffy_stores_app/shop_model/templates/shop.rb +3 -0
- data/lib/generators/spiffy_stores_app/shop_model/templates/shops.yml +3 -0
- data/lib/generators/spiffy_stores_app/spiffy_stores_app_generator.rb +16 -0
- data/lib/generators/spiffy_stores_app/views/views_generator.rb +29 -0
- data/lib/spiffy_stores_app.rb +34 -0
- data/lib/spiffy_stores_app/configuration.rb +72 -0
- data/lib/spiffy_stores_app/controller_concerns/app_proxy_verification.rb +38 -0
- data/lib/spiffy_stores_app/controller_concerns/embedded_app.rb +19 -0
- data/lib/spiffy_stores_app/controller_concerns/localization.rb +22 -0
- data/lib/spiffy_stores_app/controller_concerns/login_protection.rb +103 -0
- data/lib/spiffy_stores_app/controller_concerns/webhook_verification.rb +34 -0
- data/lib/spiffy_stores_app/engine.rb +10 -0
- data/lib/spiffy_stores_app/jobs/scripttags_manager_job.rb +15 -0
- data/lib/spiffy_stores_app/jobs/webhooks_manager_job.rb +15 -0
- data/lib/spiffy_stores_app/managers/scripttags_manager.rb +77 -0
- data/lib/spiffy_stores_app/managers/webhooks_manager.rb +61 -0
- data/lib/spiffy_stores_app/session/in_memory_session_store.rb +27 -0
- data/lib/spiffy_stores_app/session/session_repository.rb +34 -0
- data/lib/spiffy_stores_app/session/session_storage.rb +32 -0
- data/lib/spiffy_stores_app/utils.rb +16 -0
- data/lib/spiffy_stores_app/version.rb +3 -0
- data/spiffy_stores_app.gemspec +26 -0
- metadata +220 -0
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'spiffy_stores_app/version'
|
2
|
+
|
3
|
+
# deps
|
4
|
+
require 'spiffy_stores_api'
|
5
|
+
require 'omniauth-spiffy-oauth2'
|
6
|
+
|
7
|
+
# config
|
8
|
+
require 'spiffy_stores_app/configuration'
|
9
|
+
|
10
|
+
# engine
|
11
|
+
require 'spiffy_stores_app/engine'
|
12
|
+
|
13
|
+
# utils
|
14
|
+
require 'spiffy_stores_app/utils'
|
15
|
+
|
16
|
+
# controller concerns
|
17
|
+
require 'spiffy_stores_app/controller_concerns/localization'
|
18
|
+
require 'spiffy_stores_app/controller_concerns/login_protection'
|
19
|
+
require 'spiffy_stores_app/controller_concerns/embedded_app'
|
20
|
+
require 'spiffy_stores_app/controller_concerns/webhook_verification'
|
21
|
+
require 'spiffy_stores_app/controller_concerns/app_proxy_verification'
|
22
|
+
|
23
|
+
# jobs
|
24
|
+
require 'spiffy_stores_app/jobs/webhooks_manager_job'
|
25
|
+
require 'spiffy_stores_app/jobs/scripttags_manager_job'
|
26
|
+
|
27
|
+
# mangers
|
28
|
+
require 'spiffy_stores_app/managers/webhooks_manager'
|
29
|
+
require 'spiffy_stores_app/managers/scripttags_manager'
|
30
|
+
|
31
|
+
# session
|
32
|
+
require 'spiffy_stores_app/session/session_storage'
|
33
|
+
require 'spiffy_stores_app/session/session_repository'
|
34
|
+
require 'spiffy_stores_app/session/in_memory_session_store'
|
@@ -0,0 +1,72 @@
|
|
1
|
+
module SpiffyStoresApp
|
2
|
+
class Configuration
|
3
|
+
|
4
|
+
# SpiffyStores App settings. These values should match the configuration
|
5
|
+
# for the app in your SpiffyStores Partners page. Change your settings in
|
6
|
+
# `config/initializers/spiffy_stores_app.rb`
|
7
|
+
attr_accessor :application_name
|
8
|
+
attr_accessor :api_key
|
9
|
+
attr_accessor :secret
|
10
|
+
attr_accessor :scope
|
11
|
+
attr_accessor :embedded_app
|
12
|
+
alias_method :embedded_app?, :embedded_app
|
13
|
+
attr_accessor :webhooks
|
14
|
+
attr_accessor :scripttags
|
15
|
+
attr_accessor :after_authenticate_job
|
16
|
+
attr_accessor :session_repository
|
17
|
+
|
18
|
+
# customise urls
|
19
|
+
attr_accessor :root_url
|
20
|
+
|
21
|
+
# customise ActiveJob queue names
|
22
|
+
attr_accessor :scripttags_manager_queue_name
|
23
|
+
attr_accessor :webhooks_manager_queue_name
|
24
|
+
|
25
|
+
# configure spiffystores domain for local spiffy_stores development
|
26
|
+
attr_accessor :spiffy_stores_domain
|
27
|
+
|
28
|
+
# allow namespacing webhook jobs
|
29
|
+
attr_accessor :webhook_jobs_namespace
|
30
|
+
|
31
|
+
def initialize
|
32
|
+
@root_url = '/'
|
33
|
+
@spiffy_stores_domain = 'spiffystores.com'
|
34
|
+
@scripttags_manager_queue_name = Rails.application.config.active_job.queue_name
|
35
|
+
@webhooks_manager_queue_name = Rails.application.config.active_job.queue_name
|
36
|
+
end
|
37
|
+
|
38
|
+
def login_url
|
39
|
+
File.join(@root_url, 'login')
|
40
|
+
end
|
41
|
+
|
42
|
+
def session_repository=(klass)
|
43
|
+
if Rails.configuration.cache_classes
|
44
|
+
SpiffyStoresApp::SessionRepository.storage = klass
|
45
|
+
else
|
46
|
+
ActiveSupport::Reloader.to_prepare do
|
47
|
+
SpiffyStoresApp::SessionRepository.storage = klass
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def has_webhooks?
|
53
|
+
webhooks.present?
|
54
|
+
end
|
55
|
+
|
56
|
+
def has_scripttags?
|
57
|
+
scripttags.present?
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def self.configuration
|
62
|
+
@configuration ||= Configuration.new
|
63
|
+
end
|
64
|
+
|
65
|
+
def self.configuration=(config)
|
66
|
+
@configuration = config
|
67
|
+
end
|
68
|
+
|
69
|
+
def self.configure
|
70
|
+
yield configuration
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module SpiffyStoresApp
|
2
|
+
module AppProxyVerification
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
skip_before_action :verify_authenticity_token, raise: false
|
7
|
+
before_action :verify_proxy_request
|
8
|
+
end
|
9
|
+
|
10
|
+
def verify_proxy_request
|
11
|
+
return head :forbidden unless query_string_valid?(request.query_string)
|
12
|
+
end
|
13
|
+
|
14
|
+
private
|
15
|
+
|
16
|
+
def query_string_valid?(query_string)
|
17
|
+
query_hash = Rack::Utils.parse_query(query_string)
|
18
|
+
|
19
|
+
signature = query_hash.delete('signature')
|
20
|
+
return false if signature.nil?
|
21
|
+
|
22
|
+
ActiveSupport::SecurityUtils.secure_compare(
|
23
|
+
calculated_signature(query_hash),
|
24
|
+
signature
|
25
|
+
)
|
26
|
+
end
|
27
|
+
|
28
|
+
def calculated_signature(query_hash_without_signature)
|
29
|
+
sorted_params = query_hash_without_signature.collect{|k,v| "#{k}=#{Array(v).join(',')}"}.sort.join
|
30
|
+
|
31
|
+
OpenSSL::HMAC.hexdigest(
|
32
|
+
OpenSSL::Digest.new('sha256'),
|
33
|
+
SpiffyStoresApp.configuration.secret,
|
34
|
+
sorted_params
|
35
|
+
)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module SpiffyStoresApp
|
2
|
+
module EmbeddedApp
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
if SpiffyStoresApp.configuration.embedded_app?
|
7
|
+
after_action :set_esdk_headers
|
8
|
+
layout 'embedded_app'
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
def set_esdk_headers
|
15
|
+
response.set_header('P3P', 'CP="Not used"')
|
16
|
+
response.headers.except!('X-Frame-Options')
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module SpiffyStoresApp
|
2
|
+
module Localization
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
before_action :set_locale
|
7
|
+
end
|
8
|
+
|
9
|
+
private
|
10
|
+
|
11
|
+
def set_locale
|
12
|
+
if params[:locale]
|
13
|
+
session[:locale] = params[:locale]
|
14
|
+
else
|
15
|
+
session[:locale] ||= I18n.default_locale
|
16
|
+
end
|
17
|
+
I18n.locale = session[:locale]
|
18
|
+
rescue I18n::InvalidLocale
|
19
|
+
I18n.locale = I18n.default_locale
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
module SpiffyStoresApp
|
2
|
+
module LoginProtection
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
class SpiffyStoresDomainNotFound < StandardError; end
|
6
|
+
|
7
|
+
included do
|
8
|
+
rescue_from ActiveResource::UnauthorizedAccess, :with => :close_session
|
9
|
+
end
|
10
|
+
|
11
|
+
def spiffy_stores_session
|
12
|
+
if shop_session
|
13
|
+
begin
|
14
|
+
SpiffyStoresAPI::Base.activate_session(shop_session)
|
15
|
+
yield
|
16
|
+
ensure
|
17
|
+
SpiffyStoresAPI::Base.clear_session
|
18
|
+
end
|
19
|
+
else
|
20
|
+
redirect_to_login
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def shop_session
|
25
|
+
return unless session[:spiffy_stores]
|
26
|
+
@shop_session ||= SpiffyStoresApp::SessionRepository.retrieve(session[:spiffy_stores])
|
27
|
+
end
|
28
|
+
|
29
|
+
def login_again_if_different_shop
|
30
|
+
if shop_session && params[:store] && params[:store].is_a?(String) && shop_session.url != params[:store]
|
31
|
+
clear_shop_session
|
32
|
+
redirect_to_login
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
protected
|
37
|
+
|
38
|
+
def redirect_to_login
|
39
|
+
if request.xhr?
|
40
|
+
head :unauthorized
|
41
|
+
else
|
42
|
+
if request.get?
|
43
|
+
session[:return_to] = "#{request.path}?#{sanitized_params.to_query}"
|
44
|
+
end
|
45
|
+
redirect_to login_url
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def close_session
|
50
|
+
clear_shop_session
|
51
|
+
redirect_to login_url
|
52
|
+
end
|
53
|
+
|
54
|
+
def clear_shop_session
|
55
|
+
session[:spiffy_stores] = nil
|
56
|
+
session[:spiffy_stores_domain] = nil
|
57
|
+
session[:spiffy_stores_user] = nil
|
58
|
+
end
|
59
|
+
|
60
|
+
def login_url
|
61
|
+
url = SpiffyStoresApp.configuration.login_url
|
62
|
+
|
63
|
+
if params[:store].present?
|
64
|
+
query = { store: sanitized_params[:store] }.to_query
|
65
|
+
url = "#{url}?#{query}"
|
66
|
+
end
|
67
|
+
|
68
|
+
url
|
69
|
+
end
|
70
|
+
|
71
|
+
def fullpage_redirect_to(url)
|
72
|
+
if SpiffyStoresApp.configuration.embedded_app?
|
73
|
+
render 'spiffy_stores_app/shared/redirect', layout: false, locals: { url: url, current_spiffy_stores_domain: current_spiffy_stores_domain }
|
74
|
+
else
|
75
|
+
redirect_to url
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
def current_spiffy_stores_domain
|
80
|
+
spiffy_stores_domain = sanitized_shop_name || session[:spiffy_stores_domain]
|
81
|
+
return spiffy_stores_domain if spiffy_stores_domain.present?
|
82
|
+
|
83
|
+
raise SpiffyStoresDomainNotFound
|
84
|
+
end
|
85
|
+
|
86
|
+
def sanitized_shop_name
|
87
|
+
@sanitized_shop_name ||= sanitize_shop_param(params)
|
88
|
+
end
|
89
|
+
|
90
|
+
def sanitize_shop_param(params)
|
91
|
+
return unless params[:store].present?
|
92
|
+
SpiffyStoresApp::Utils.sanitize_shop_domain(params[:store])
|
93
|
+
end
|
94
|
+
|
95
|
+
def sanitized_params
|
96
|
+
request.query_parameters.clone.tap do |query_params|
|
97
|
+
if params[:store].is_a?(String)
|
98
|
+
query_params[:store] = sanitize_shop_param(params)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module SpiffyStoresApp
|
2
|
+
module WebhookVerification
|
3
|
+
extend ActiveSupport::Concern
|
4
|
+
|
5
|
+
included do
|
6
|
+
skip_before_action :verify_authenticity_token, raise: false
|
7
|
+
before_action :verify_request
|
8
|
+
end
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def verify_request
|
13
|
+
data = request.raw_post
|
14
|
+
return head :unauthorized unless hmac_valid?(data)
|
15
|
+
end
|
16
|
+
|
17
|
+
def hmac_valid?(data)
|
18
|
+
secret = SpiffyStoresApp.configuration.secret
|
19
|
+
digest = OpenSSL::Digest.new('sha256')
|
20
|
+
ActiveSupport::SecurityUtils.secure_compare(
|
21
|
+
spiffy_stores_hmac,
|
22
|
+
Base64.encode64(OpenSSL::HMAC.digest(digest, secret, data)).strip
|
23
|
+
)
|
24
|
+
end
|
25
|
+
|
26
|
+
def shop_domain
|
27
|
+
request.headers['HTTP_X_SPIFFY_STORES_SHOP_DOMAIN']
|
28
|
+
end
|
29
|
+
|
30
|
+
def spiffy_stores_hmac
|
31
|
+
request.headers['HTTP_X_SPIFFY_STORES_HMAC_SHA256']
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
module SpiffyStoresApp
|
2
|
+
class Engine < Rails::Engine
|
3
|
+
engine_name 'spiffy_stores_app'
|
4
|
+
isolate_namespace SpiffyStoresApp
|
5
|
+
|
6
|
+
initializer "spiffy_stores_app.assets.precompile" do |app|
|
7
|
+
app.config.assets.precompile += %w( spiffy_stores_app/redirect.js )
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module SpiffyStoresApp
|
2
|
+
class ScripttagsManagerJob < ActiveJob::Base
|
3
|
+
|
4
|
+
queue_as do
|
5
|
+
SpiffyStoresApp.configuration.scripttags_manager_queue_name
|
6
|
+
end
|
7
|
+
|
8
|
+
def perform(shop_domain:, shop_token:, scripttags:)
|
9
|
+
SpiffyStoresAPI::Session.temp(shop_domain, shop_token) do
|
10
|
+
manager = ScripttagsManager.new(scripttags, shop_domain)
|
11
|
+
manager.create_scripttags
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module SpiffyStoresApp
|
2
|
+
class WebhooksManagerJob < ActiveJob::Base
|
3
|
+
|
4
|
+
queue_as do
|
5
|
+
SpiffyStoresApp.configuration.webhooks_manager_queue_name
|
6
|
+
end
|
7
|
+
|
8
|
+
def perform(shop_domain:, shop_token:, webhooks:)
|
9
|
+
SpiffyStoresAPI::Session.temp(shop_domain, shop_token) do
|
10
|
+
manager = WebhooksManager.new(webhooks)
|
11
|
+
manager.create_webhooks
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
module SpiffyStoresApp
|
2
|
+
class ScripttagsManager
|
3
|
+
class CreationFailed < StandardError; end
|
4
|
+
|
5
|
+
def self.queue(shop_domain, shop_token, scripttags)
|
6
|
+
SpiffyStoresApp::ScripttagsManagerJob.perform_later(
|
7
|
+
shop_domain: shop_domain,
|
8
|
+
shop_token: shop_token,
|
9
|
+
# Procs cannot be serialized so we interpolate now, if necessary
|
10
|
+
scripttags: build_src(scripttags, shop_domain)
|
11
|
+
)
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.build_src(scripttags, domain)
|
15
|
+
scripttags.map do |tag|
|
16
|
+
next tag unless tag[:src].respond_to?(:call)
|
17
|
+
tag = tag.dup
|
18
|
+
tag[:src] = tag[:src].call(domain)
|
19
|
+
tag
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
attr_reader :required_scripttags, :shop_domain
|
24
|
+
|
25
|
+
def initialize(scripttags, shop_domain)
|
26
|
+
@required_scripttags = scripttags
|
27
|
+
@shop_domain = shop_domain
|
28
|
+
end
|
29
|
+
|
30
|
+
def recreate_scripttags!
|
31
|
+
destroy_scripttags
|
32
|
+
create_scripttags
|
33
|
+
end
|
34
|
+
|
35
|
+
def create_scripttags
|
36
|
+
return unless required_scripttags.present?
|
37
|
+
|
38
|
+
expanded_scripttags.each do |scripttag|
|
39
|
+
create_scripttag(scripttag) unless scripttag_exists?(scripttag[:src])
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def destroy_scripttags
|
44
|
+
scripttags = expanded_scripttags
|
45
|
+
SpiffyStoresAPI::ScriptTag.all.each do |tag|
|
46
|
+
SpiffyStoresAPI::ScriptTag.delete(tag.id) if is_required_scripttag?(scripttags, tag)
|
47
|
+
end
|
48
|
+
|
49
|
+
@current_scripttags = nil
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
def expanded_scripttags
|
55
|
+
self.class.build_src(required_scripttags, shop_domain)
|
56
|
+
end
|
57
|
+
|
58
|
+
def is_required_scripttag?(scripttags, tag)
|
59
|
+
scripttags.map{ |w| w[:src] }.include? tag.src
|
60
|
+
end
|
61
|
+
|
62
|
+
def create_scripttag(attributes)
|
63
|
+
attributes.reverse_merge!(format: 'json')
|
64
|
+
scripttag = SpiffyStoresAPI::ScriptTag.create(attributes)
|
65
|
+
raise CreationFailed, scripttag.errors.full_messages.to_sentence unless scripttag.persisted?
|
66
|
+
scripttag
|
67
|
+
end
|
68
|
+
|
69
|
+
def scripttag_exists?(src)
|
70
|
+
current_scripttags[src]
|
71
|
+
end
|
72
|
+
|
73
|
+
def current_scripttags
|
74
|
+
@current_scripttags ||= SpiffyStoresAPI::ScriptTag.all.index_by(&:src)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|