tinypass 0.0.1
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.
- checksums.yaml +15 -0
- data/.DS_Store +0 -0
- data/.gitignore +20 -0
- data/.rspec +2 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +32 -0
- data/Rakefile +19 -0
- data/lib/.DS_Store +0 -0
- data/lib/tinypass.rb +48 -0
- data/lib/tinypass/.DS_Store +0 -0
- data/lib/tinypass/builder.rb +7 -0
- data/lib/tinypass/builder/client_builder.rb +40 -0
- data/lib/tinypass/builder/client_parser.rb +48 -0
- data/lib/tinypass/builder/cookie_parser.rb +25 -0
- data/lib/tinypass/builder/json_msg_builder.rb +115 -0
- data/lib/tinypass/builder/open_encoder.rb +11 -0
- data/lib/tinypass/builder/secure_encoder.rb +15 -0
- data/lib/tinypass/builder/security_utils.rb +67 -0
- data/lib/tinypass/gateway.rb +104 -0
- data/lib/tinypass/offer.rb +23 -0
- data/lib/tinypass/policies.rb +5 -0
- data/lib/tinypass/policies/discount_policy.rb +25 -0
- data/lib/tinypass/policies/policy.rb +25 -0
- data/lib/tinypass/policies/pricing_policy.rb +13 -0
- data/lib/tinypass/policies/restriction_policy.rb +14 -0
- data/lib/tinypass/price_option.rb +66 -0
- data/lib/tinypass/resource.rb +17 -0
- data/lib/tinypass/token.rb +6 -0
- data/lib/tinypass/token/access_token.rb +163 -0
- data/lib/tinypass/token/access_token_list.rb +71 -0
- data/lib/tinypass/token/access_token_store.rb +59 -0
- data/lib/tinypass/token/meter.rb +76 -0
- data/lib/tinypass/token/meter_helper.rb +82 -0
- data/lib/tinypass/token/token_data.rb +72 -0
- data/lib/tinypass/ui.rb +2 -0
- data/lib/tinypass/ui/html_widget.rb +29 -0
- data/lib/tinypass/ui/purchase_request.rb +34 -0
- data/lib/tinypass/utils.rb +34 -0
- data/lib/tinypass/version.rb +3 -0
- data/spec/.DS_Store +0 -0
- data/spec/acceptance/basic_workflow_spec.rb +81 -0
- data/spec/integration/.DS_Store +0 -0
- data/spec/integration/cases/.DS_Store +0 -0
- data/spec/integration/cases/basic_spec.rb +53 -0
- data/spec/integration/cases/combo_spec.rb +43 -0
- data/spec/integration/cases/metered_reminder_spec.rb +42 -0
- data/spec/integration/cases/metered_strict_spec.rb +54 -0
- data/spec/integration/cases/metered_views_spec.rb +92 -0
- data/spec/integration/client_builder_and_parser_spec.rb +21 -0
- data/spec/spec_helper.rb +33 -0
- data/spec/support/.DS_Store +0 -0
- data/spec/support/acceptance.rb +13 -0
- data/spec/support/tinypass_factories.rb +25 -0
- data/spec/unit/.DS_Store +0 -0
- data/spec/unit/builder/.DS_Store +0 -0
- data/spec/unit/builder/client_builder_spec.rb +23 -0
- data/spec/unit/builder/client_parser_spec.rb +27 -0
- data/spec/unit/builder/cookie_parser_spec.rb +39 -0
- data/spec/unit/builder/json_msg_builder_spec.rb +73 -0
- data/spec/unit/builder/open_encoder_spec.rb +15 -0
- data/spec/unit/builder/secure_encoder_spec.rb +23 -0
- data/spec/unit/builder/security_utils_spec.rb +42 -0
- data/spec/unit/gateway_spec.rb +80 -0
- data/spec/unit/offer_spec.rb +31 -0
- data/spec/unit/policies/.DS_Store +0 -0
- data/spec/unit/policies/discount_policy_spec.rb +40 -0
- data/spec/unit/policies/pricing_policy_spec.rb +23 -0
- data/spec/unit/policies/restriction_policy_spec.rb +35 -0
- data/spec/unit/price_option_spec.rb +109 -0
- data/spec/unit/resource_spec.rb +24 -0
- data/spec/unit/tinypass_spec.rb +51 -0
- data/spec/unit/token/.DS_Store +0 -0
- data/spec/unit/token/access_token_list_spec.rb +94 -0
- data/spec/unit/token/access_token_spec.rb +267 -0
- data/spec/unit/token/access_token_store_spec.rb +93 -0
- data/spec/unit/token/meter_helper_spec.rb +103 -0
- data/spec/unit/token/meter_spec.rb +66 -0
- data/spec/unit/token/token_data_spec.rb +66 -0
- data/spec/unit/ui/.DS_Store +0 -0
- data/spec/unit/ui/html_widget_spec.rb +89 -0
- data/spec/unit/ui/purchase_request_spec.rb +46 -0
- data/spec/unit/utils_spec.rb +57 -0
- data/tinypass.gemspec +35 -0
- metadata +330 -0
@@ -0,0 +1,71 @@
|
|
1
|
+
module Tinypass
|
2
|
+
class AccessTokenList
|
3
|
+
include Enumerable
|
4
|
+
|
5
|
+
MAX = 20
|
6
|
+
|
7
|
+
def initialize(input_tokens = nil)
|
8
|
+
@tokens_hash = {}
|
9
|
+
input_tokens = Array(input_tokens)
|
10
|
+
|
11
|
+
input_tokens.each { |token| self << token }
|
12
|
+
end
|
13
|
+
|
14
|
+
def tokens
|
15
|
+
@tokens_hash.values
|
16
|
+
end
|
17
|
+
alias_method :access_tokens, :tokens
|
18
|
+
|
19
|
+
def [](rid)
|
20
|
+
@tokens_hash[rid.to_s]
|
21
|
+
end
|
22
|
+
|
23
|
+
def <<(token)
|
24
|
+
key = token.token_data.rid
|
25
|
+
|
26
|
+
@tokens_hash[key] = token
|
27
|
+
shift until size <= MAX
|
28
|
+
|
29
|
+
self[key]
|
30
|
+
end
|
31
|
+
alias_method :add, :<<
|
32
|
+
|
33
|
+
def push(*args)
|
34
|
+
args.each do |token|
|
35
|
+
self << token
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def add_all(tokens)
|
40
|
+
self.push(*tokens)
|
41
|
+
end
|
42
|
+
|
43
|
+
def include?(rid)
|
44
|
+
rid = rid.to_s
|
45
|
+
@tokens_hash.has_key?(rid)
|
46
|
+
end
|
47
|
+
alias_method :contains?, :include?
|
48
|
+
|
49
|
+
def each(*args, &block)
|
50
|
+
tokens.each(*args, &block)
|
51
|
+
end
|
52
|
+
|
53
|
+
def length
|
54
|
+
tokens.size
|
55
|
+
end
|
56
|
+
alias_method :size, :length
|
57
|
+
|
58
|
+
def empty?
|
59
|
+
@tokens_hash.empty?
|
60
|
+
end
|
61
|
+
|
62
|
+
def delete(rid)
|
63
|
+
@tokens_hash.delete(rid)
|
64
|
+
end
|
65
|
+
alias_method :remove, :delete
|
66
|
+
|
67
|
+
def shift
|
68
|
+
delete(@tokens_hash.keys.first)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require 'uri'
|
2
|
+
|
3
|
+
require 'tinypass/token/access_token.rb'
|
4
|
+
require 'tinypass/token/token_data.rb'
|
5
|
+
|
6
|
+
module Tinypass
|
7
|
+
class AccessTokenStore
|
8
|
+
attr_reader :tokens, :raw_cookie
|
9
|
+
|
10
|
+
def initialize(config = nil)
|
11
|
+
@config = config
|
12
|
+
@tokens = AccessTokenList.new
|
13
|
+
end
|
14
|
+
|
15
|
+
def load_tokens_from_cookie(cookies, name = nil)
|
16
|
+
name ||= Config.token_cookie_name(Tinypass.aid)
|
17
|
+
@raw_cookie = cookies.respond_to?(:to_str) ? cookies : cookies[name]
|
18
|
+
|
19
|
+
if @raw_cookie
|
20
|
+
@tokens = ClientParser.new.parse_access_tokens(URI.unescape(raw_cookie))
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def get_access_token(rid)
|
25
|
+
rid = rid.to_s
|
26
|
+
return tokens[rid] if tokens[rid]
|
27
|
+
|
28
|
+
token = AccessToken.new(rid, -1)
|
29
|
+
|
30
|
+
if tokens.size == 0
|
31
|
+
token.access_state = AccessState::NO_TOKENS_FOUND
|
32
|
+
else
|
33
|
+
token.access_state = AccessState::RID_NOT_FOUND
|
34
|
+
end
|
35
|
+
|
36
|
+
return token
|
37
|
+
end
|
38
|
+
|
39
|
+
def has_token?(rid)
|
40
|
+
tokens.contains?(rid.to_s)
|
41
|
+
end
|
42
|
+
|
43
|
+
def find_active_token(regexp)
|
44
|
+
tokens.each do |token|
|
45
|
+
return token if token.rid =~ regexp && !token.expired?
|
46
|
+
end
|
47
|
+
|
48
|
+
nil
|
49
|
+
end
|
50
|
+
|
51
|
+
protected
|
52
|
+
|
53
|
+
def clean_expired_tokens
|
54
|
+
@tokens.dup.each do |token|
|
55
|
+
@tokens.delete(token.rid) if token.expired? || (token.metered? && token.trial_dead?)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
module Tinypass
|
2
|
+
class Meter
|
3
|
+
def initialize(access_token)
|
4
|
+
@access_token = access_token
|
5
|
+
end
|
6
|
+
|
7
|
+
def self.create_view_based(rid, max_views, trial_period)
|
8
|
+
access_token = AccessToken.new(rid)
|
9
|
+
end_time = Utils.parse_loose_period_in_secs(trial_period) + Time.now.to_i
|
10
|
+
|
11
|
+
access_token.token_data[TokenData::METER_TYPE] = TokenData::METER_REMINDER
|
12
|
+
access_token.token_data[TokenData::METER_TRIAL_MAX_ACCESS_ATTEMPTS] = max_views
|
13
|
+
access_token.token_data[TokenData::METER_TRIAL_ACCESS_ATTEMPTS] = 0
|
14
|
+
access_token.token_data[TokenData::METER_TRIAL_ENDTIME] = end_time
|
15
|
+
access_token.token_data[TokenData::METER_LOCKOUT_ENDTIME] = end_time
|
16
|
+
|
17
|
+
new(access_token)
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.create_time_based(rid, trial_period, lockout_period)
|
21
|
+
access_token = AccessToken.new(rid)
|
22
|
+
trial_end_time = Utils::parse_loose_period_in_secs(trial_period) + Time.now.to_i
|
23
|
+
lockout_end_time = trial_end_time + Utils::parse_loose_period_in_secs(lockout_period)
|
24
|
+
|
25
|
+
access_token.token_data[TokenData::METER_TYPE] = TokenData::METER_REMINDER
|
26
|
+
access_token.token_data[TokenData::METER_TRIAL_ENDTIME] = trial_end_time
|
27
|
+
access_token.token_data[TokenData::METER_LOCKOUT_ENDTIME] = lockout_end_time
|
28
|
+
|
29
|
+
new(access_token)
|
30
|
+
end
|
31
|
+
|
32
|
+
def increment
|
33
|
+
data[TokenData::METER_TRIAL_ACCESS_ATTEMPTS] = trial_view_count + 1
|
34
|
+
end
|
35
|
+
|
36
|
+
def trial_period_active?
|
37
|
+
@access_token.trial_period_active?
|
38
|
+
end
|
39
|
+
|
40
|
+
def lockout_period_active?
|
41
|
+
@access_token.lockout_period_active?
|
42
|
+
end
|
43
|
+
|
44
|
+
def data
|
45
|
+
@access_token.token_data
|
46
|
+
end
|
47
|
+
|
48
|
+
def view_based?
|
49
|
+
@access_token.meter_view_based?
|
50
|
+
end
|
51
|
+
|
52
|
+
def trial_view_count
|
53
|
+
@access_token.trial_view_count
|
54
|
+
end
|
55
|
+
|
56
|
+
def trial_view_limit
|
57
|
+
@access_token.trial_view_limit
|
58
|
+
end
|
59
|
+
|
60
|
+
def trial_dead?
|
61
|
+
@access_token.trial_dead?
|
62
|
+
end
|
63
|
+
|
64
|
+
def meter_type
|
65
|
+
@access_token.meter_type
|
66
|
+
end
|
67
|
+
|
68
|
+
def trial_end_time_secs
|
69
|
+
@access_token.trial_end_time_secs
|
70
|
+
end
|
71
|
+
|
72
|
+
def lockout_end_time_secs
|
73
|
+
@access_token.lockout_end_time_secs
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,82 @@
|
|
1
|
+
module Tinypass
|
2
|
+
module MeterHelper
|
3
|
+
extend self
|
4
|
+
|
5
|
+
def create_view_based(name, max_views, within_period)
|
6
|
+
Meter.create_view_based(name, max_views, within_period)
|
7
|
+
end
|
8
|
+
|
9
|
+
def create_time_based(name, trial_period, lockout_period)
|
10
|
+
Meter.create_time_based(name, trial_period, lockout_period)
|
11
|
+
end
|
12
|
+
|
13
|
+
def load_meter_from_cookie(meter_name, cookies)
|
14
|
+
# NOTE: This method expects `meter_name` to be both the cookie name and the meter name
|
15
|
+
# aka rid.
|
16
|
+
|
17
|
+
store = AccessTokenStore.new
|
18
|
+
store.load_tokens_from_cookie(cookies, meter_name)
|
19
|
+
|
20
|
+
return unless store.has_token?(meter_name)
|
21
|
+
|
22
|
+
token = store.get_access_token(meter_name)
|
23
|
+
meter = Meter.new(token)
|
24
|
+
|
25
|
+
return if meter.trial_dead?
|
26
|
+
meter
|
27
|
+
end
|
28
|
+
|
29
|
+
def load_meter_from_serialized_data(string)
|
30
|
+
store = AccessTokenStore.new
|
31
|
+
store.load_tokens_from_cookie(string)
|
32
|
+
token = store.tokens.first
|
33
|
+
|
34
|
+
return if token.nil?
|
35
|
+
|
36
|
+
meter = Meter.new(token)
|
37
|
+
|
38
|
+
return if meter.trial_dead?
|
39
|
+
meter
|
40
|
+
end
|
41
|
+
|
42
|
+
def serialize(meter, builder_config = '')
|
43
|
+
token = AccessToken.new(meter.data)
|
44
|
+
builder = ClientBuilder.new(builder_config)
|
45
|
+
builder.build_access_tokens(token)
|
46
|
+
end
|
47
|
+
|
48
|
+
def serialize_to_json(meter)
|
49
|
+
serialize(meter, ClientBuilder::OPEN_ENC)
|
50
|
+
end
|
51
|
+
|
52
|
+
def deserialize(string)
|
53
|
+
parser = ClientParser.new
|
54
|
+
list = parser.parse_access_tokens(string)
|
55
|
+
token = list.first
|
56
|
+
|
57
|
+
return if token.nil?
|
58
|
+
|
59
|
+
Meter.new(token)
|
60
|
+
end
|
61
|
+
|
62
|
+
def generate_cookie_embed_script(name, meter)
|
63
|
+
if meter.lockout_period_active?
|
64
|
+
expires = meter.lockout_end_time_secs + 60
|
65
|
+
else
|
66
|
+
expires = Time.now.to_i + 60 * 60 * 24 * 90
|
67
|
+
end
|
68
|
+
expires_string = Time.at(expires).utc
|
69
|
+
|
70
|
+
"<script>
|
71
|
+
document.cookie='#{ generate_local_cookie(name, meter) }; path=/; expires=#{ expires_string };';
|
72
|
+
</script>"
|
73
|
+
end
|
74
|
+
|
75
|
+
private
|
76
|
+
|
77
|
+
def generate_local_cookie(name, meter)
|
78
|
+
cookie_value = URI::escape(serialize(meter))
|
79
|
+
"#{ name }=#{ cookie_value }"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
module Tinypass
|
2
|
+
class TokenData
|
3
|
+
MARK_YEAR_MILLIS = 1293858000000
|
4
|
+
|
5
|
+
METER_REMINDER = 10
|
6
|
+
METER_STRICT = 20
|
7
|
+
|
8
|
+
METER_TRIAL_ENDTIME = 'mtet'
|
9
|
+
METER_TRIAL_ACCESS_PERIOD = 'mtap'
|
10
|
+
|
11
|
+
METER_LOCKOUT_ENDTIME = 'mlet'
|
12
|
+
METER_LOCKOUT_PERIOD = 'mlp'
|
13
|
+
|
14
|
+
METER_TRIAL_MAX_ACCESS_ATTEMPTS = 'mtma'
|
15
|
+
METER_TRIAL_ACCESS_ATTEMPTS = 'mtaa'
|
16
|
+
METER_TYPE = 'mt'
|
17
|
+
|
18
|
+
ACCESS_ID = 'id'
|
19
|
+
|
20
|
+
RID = 'rid'
|
21
|
+
UID = 'uid'
|
22
|
+
EX = 'ex'
|
23
|
+
EARLY_EX = 'eex'
|
24
|
+
IPS = 'ips'
|
25
|
+
|
26
|
+
def initialize(data = {})
|
27
|
+
@data = data
|
28
|
+
end
|
29
|
+
|
30
|
+
def rid
|
31
|
+
@data[RID]
|
32
|
+
end
|
33
|
+
|
34
|
+
def [](key)
|
35
|
+
key = key.to_s
|
36
|
+
@data[key]
|
37
|
+
end
|
38
|
+
|
39
|
+
def []=(key, value)
|
40
|
+
key = key.to_s
|
41
|
+
@data[key] = value
|
42
|
+
end
|
43
|
+
|
44
|
+
def values
|
45
|
+
@data
|
46
|
+
end
|
47
|
+
|
48
|
+
def fetch(*args)
|
49
|
+
args[0] = args[0].to_s
|
50
|
+
@data.fetch(*args)
|
51
|
+
end
|
52
|
+
|
53
|
+
def merge(hash)
|
54
|
+
stringified_hash = {}
|
55
|
+
hash.keys.each do |key|
|
56
|
+
stringified_hash[key.to_s] = hash[key]
|
57
|
+
end
|
58
|
+
|
59
|
+
@data.merge!(stringified_hash)
|
60
|
+
end
|
61
|
+
alias_method :add_fields, :merge
|
62
|
+
|
63
|
+
def size
|
64
|
+
@data.size
|
65
|
+
end
|
66
|
+
|
67
|
+
def self.convert_to_epoch_seconds(seconds_from_now)
|
68
|
+
seconds_from_now /= 1000 if seconds_from_now > MARK_YEAR_MILLIS
|
69
|
+
seconds_from_now
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
data/lib/tinypass/ui.rb
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
module Tinypass
|
2
|
+
class HtmlWidget
|
3
|
+
def create_button_html(request)
|
4
|
+
options = request.options.dup || {}
|
5
|
+
rid = request.primary_offer.resource.rid
|
6
|
+
builder = ClientBuilder.new
|
7
|
+
rdata = builder.build_purchase_request(request).gsub('"', '\"')
|
8
|
+
|
9
|
+
html = "<tp:request type=\"purchase\" rid=\"#{ rid }\"" <<
|
10
|
+
" url=\"#{ Config.endpoint + Config::CONTEXT }\"" <<
|
11
|
+
" rdata=\"#{ rdata }\" aid=\"#{ Tinypass.aid }\"" <<
|
12
|
+
" cn=\"#{ Config.token_cookie_name }\" v=\"#{ Config::VERSION }\""
|
13
|
+
|
14
|
+
html << " oncheckaccess=\"#{ request.callback }\"" if request.callback
|
15
|
+
|
16
|
+
if options['button.html']
|
17
|
+
custom = options['button.html'].gsub('"', '"')
|
18
|
+
html << " custom=\"#{ custom }\""
|
19
|
+
elsif options['button.link']
|
20
|
+
link = options['button.link'].gsub('"', '"')
|
21
|
+
html << " link=\"#{ link }\""
|
22
|
+
end
|
23
|
+
|
24
|
+
html << "></tp:request>"
|
25
|
+
|
26
|
+
html
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Tinypass
|
2
|
+
class PurchaseRequest
|
3
|
+
attr_accessor :primary_offer, :secondary_offer, :options, :callback, :client_ip, :user_ref
|
4
|
+
|
5
|
+
def initialize(offer, options = {})
|
6
|
+
@primary_offer, @options = offer, options
|
7
|
+
end
|
8
|
+
|
9
|
+
def generate_tag
|
10
|
+
widget = HtmlWidget.new
|
11
|
+
widget.create_button_html(self)
|
12
|
+
end
|
13
|
+
|
14
|
+
def generate_link(return_url, cancel_url)
|
15
|
+
self.options['return_url'] = return_url if return_url
|
16
|
+
self.options['cancel_url'] = cancel_url if cancel_url
|
17
|
+
|
18
|
+
builder = ClientBuilder.new
|
19
|
+
ticket_string = builder.build_purchase_request(self)
|
20
|
+
|
21
|
+
Config.endpoint + Config::CONTEXT + "/jsapi/auth.js?aid=#{ Tinypass.aid }&r=#{ ticket_string }"
|
22
|
+
end
|
23
|
+
|
24
|
+
def client_ip=(value)
|
25
|
+
value.strip! if value
|
26
|
+
@client_ip = value
|
27
|
+
end
|
28
|
+
|
29
|
+
def user_ref=(value)
|
30
|
+
value.strip! if value
|
31
|
+
@user_ref = value
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Tinypass
|
2
|
+
module Utils
|
3
|
+
extend self
|
4
|
+
|
5
|
+
def valid_ip?(ip)
|
6
|
+
ip && ip =~ /\d{1,3}\.\d{1,3}\.\d{1,3}.\d{1,3}/
|
7
|
+
end
|
8
|
+
|
9
|
+
def parse_loose_period_in_msecs(period)
|
10
|
+
period = period.to_s
|
11
|
+
return period.to_i if period.to_i.to_s == period
|
12
|
+
|
13
|
+
if matches = /(\d+)\s*(\w+)/.match(period)
|
14
|
+
number = matches[1].to_i
|
15
|
+
string = matches[2]
|
16
|
+
|
17
|
+
return number if string.start_with?("ms")
|
18
|
+
return number * 1000 if string.start_with?("s")
|
19
|
+
return number * 1000 * 60 if string.start_with?("mi")
|
20
|
+
return number * 1000 * 60 * 60 if string.start_with?("h")
|
21
|
+
return number * 1000 * 60 * 60 * 24 if string.start_with?("d")
|
22
|
+
return number * 1000 * 60 * 60 * 24 * 7 if string.start_with?("w")
|
23
|
+
return number * 1000 * 60 * 60 * 24 * 30 if string.start_with?("mo")
|
24
|
+
return number * 1000 * 60 * 60 * 24 * 365 if string.start_with?("y")
|
25
|
+
end
|
26
|
+
|
27
|
+
raise ArgumentError.new("Cannot parse the specified period: #{ period }")
|
28
|
+
end
|
29
|
+
|
30
|
+
def parse_loose_period_in_secs(period)
|
31
|
+
parse_loose_period_in_msecs(period) / 1000
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|