yoti 1.5.0 → 1.6.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.
- checksums.yaml +5 -5
- data/Gemfile +0 -2
- data/README.md +1 -1
- data/Rakefile +8 -0
- data/lib/yoti.rb +16 -0
- data/lib/yoti/activity_details.rb +17 -2
- data/lib/yoti/client.rb +2 -1
- data/lib/yoti/data_type/age_verification.rb +54 -0
- data/lib/yoti/data_type/attribute.rb +3 -0
- data/lib/yoti/data_type/base_profile.rb +13 -0
- data/lib/yoti/data_type/document_details.rb +96 -0
- data/lib/yoti/data_type/profile.rb +76 -0
- data/lib/yoti/dynamic_share_service/dynamic_scenario.rb +67 -0
- data/lib/yoti/dynamic_share_service/extension/extension.rb +45 -0
- data/lib/yoti/dynamic_share_service/extension/location_constraint_extension.rb +88 -0
- data/lib/yoti/dynamic_share_service/extension/thirdparty_attribute_extension.rb +58 -0
- data/lib/yoti/dynamic_share_service/extension/transactional_flow_extension.rb +47 -0
- data/lib/yoti/dynamic_share_service/policy/dynamic_policy.rb +184 -0
- data/lib/yoti/dynamic_share_service/policy/source_constraint.rb +88 -0
- data/lib/yoti/dynamic_share_service/policy/wanted_anchor.rb +53 -0
- data/lib/yoti/dynamic_share_service/policy/wanted_attribute.rb +85 -0
- data/lib/yoti/dynamic_share_service/share_url.rb +80 -0
- data/lib/yoti/http/profile_request.rb +1 -0
- data/lib/yoti/http/request.rb +13 -0
- data/lib/yoti/http/signed_request.rb +0 -3
- data/lib/yoti/protobuf/main.rb +11 -0
- data/lib/yoti/protobuf/sharepubapi/DataEntry_pb.rb +29 -0
- data/lib/yoti/protobuf/sharepubapi/ExtraData_pb.rb +19 -0
- data/lib/yoti/protobuf/sharepubapi/IssuingAttributes_pb.rb +23 -0
- data/lib/yoti/protobuf/sharepubapi/ThirdPartyAttribute_pb.rb +20 -0
- data/lib/yoti/sandbox.rb +5 -0
- data/lib/yoti/sandbox/anchor.rb +49 -0
- data/lib/yoti/sandbox/attribute.rb +52 -0
- data/lib/yoti/sandbox/profile.rb +171 -0
- data/lib/yoti/sandbox/sandbox.rb +105 -0
- data/lib/yoti/sandbox/sandbox_client.rb +45 -0
- data/lib/yoti/share/attribute_issuance_details.rb +43 -0
- data/lib/yoti/share/extra_data.rb +25 -0
- data/lib/yoti/ssl.rb +7 -0
- data/lib/yoti/util/age_processor.rb +4 -0
- data/lib/yoti/version.rb +1 -1
- data/rubocop.yml +4 -0
- data/yoti.gemspec +4 -2
- metadata +61 -4
- data/.travis.yml +0 -17
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yoti
|
|
4
|
+
module DynamicSharingService
|
|
5
|
+
# A wanted anchor for a source based constraint
|
|
6
|
+
class WantedAnchor
|
|
7
|
+
attr_reader :value
|
|
8
|
+
attr_reader :sub_type
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@value = ''
|
|
12
|
+
@sub_type = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_json(*_args)
|
|
16
|
+
as_json.to_json
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def as_json
|
|
20
|
+
obj = {
|
|
21
|
+
name: @value
|
|
22
|
+
}
|
|
23
|
+
obj[:sub_type] = @sub_type if @sub_type
|
|
24
|
+
obj
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.builder
|
|
28
|
+
WantedAnchorBuilder.new
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Builder for WantedAnchor
|
|
33
|
+
class WantedAnchorBuilder
|
|
34
|
+
def initialize
|
|
35
|
+
@anchor = WantedAnchor.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def with_value(value)
|
|
39
|
+
@anchor.instance_variable_set(:@value, value)
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def with_sub_type(sub_type = nil)
|
|
44
|
+
@anchor.instance_variable_set(:@sub_type, sub_type)
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build
|
|
49
|
+
Marshal.load Marshal.dump @anchor
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yoti
|
|
4
|
+
module DynamicSharingService
|
|
5
|
+
# Describes a wanted attribute in a dynamic sharing policy
|
|
6
|
+
class WantedAttribute
|
|
7
|
+
attr_reader :name
|
|
8
|
+
attr_reader :derivation
|
|
9
|
+
attr_reader :constraints
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@constraints = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def accept_self_asserted
|
|
16
|
+
return true if @accept_self_asserted
|
|
17
|
+
|
|
18
|
+
false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_json(*_args)
|
|
22
|
+
as_json.to_json
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def as_json(*_args)
|
|
26
|
+
obj = {
|
|
27
|
+
name: @name
|
|
28
|
+
}
|
|
29
|
+
obj[:derivation] = @derivation if derivation
|
|
30
|
+
obj[:accept_self_asserted] = @accept_self_asserted if accept_self_asserted
|
|
31
|
+
obj[:constraints] = @constraints.map(&:as_json) unless constraints.empty?
|
|
32
|
+
obj
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.builder
|
|
36
|
+
WantedAttributeBuilder.new
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Builder for WantedAttribute
|
|
41
|
+
class WantedAttributeBuilder
|
|
42
|
+
def initialize
|
|
43
|
+
@attribute = WantedAttribute.new
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
#
|
|
47
|
+
# @param [String] name
|
|
48
|
+
#
|
|
49
|
+
def with_name(name)
|
|
50
|
+
@attribute.instance_variable_set(:@name, name)
|
|
51
|
+
self
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
#
|
|
55
|
+
# @param [String] derivation
|
|
56
|
+
#
|
|
57
|
+
def with_derivation(derivation)
|
|
58
|
+
@attribute.instance_variable_set(:@derivation, derivation)
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
#
|
|
63
|
+
# @param constraint Constraint to apply to the requested attribute
|
|
64
|
+
#
|
|
65
|
+
def with_constraint(constraint)
|
|
66
|
+
@attribute.constraints.push(constraint)
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
#
|
|
71
|
+
# @param [Bool] accept
|
|
72
|
+
#
|
|
73
|
+
def with_accept_self_asserted(accept = true)
|
|
74
|
+
@attribute.instance_variable_set(:@accept_self_asserted, accept)
|
|
75
|
+
self
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build
|
|
79
|
+
raise 'Attribute name missing' if @attribute.name.nil? || @attribute.name == ''
|
|
80
|
+
|
|
81
|
+
Marshal.load Marshal.dump @attribute
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yoti
|
|
4
|
+
module DynamicSharingService
|
|
5
|
+
class Share
|
|
6
|
+
attr_reader :share_url, :ref_id
|
|
7
|
+
|
|
8
|
+
def initialize(data)
|
|
9
|
+
@share_url = data['qrcode']
|
|
10
|
+
@ref_id = data['ref_id']
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.create_share_url_endpoint
|
|
15
|
+
"/qrcodes/apps/#{Yoti.configuration.client_sdk_id}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.create_share_url_query
|
|
19
|
+
"?nonce=#{SecureRandom.uuid}×tamp=#{Time.now.to_i}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.create_share_url(scenario)
|
|
23
|
+
endpoint = "#{create_share_url_endpoint}#{create_share_url_query}"
|
|
24
|
+
uri = URI("#{Yoti.configuration.api_endpoint}#{endpoint}")
|
|
25
|
+
|
|
26
|
+
unsigned = Net::HTTP::Post.new uri
|
|
27
|
+
unsigned.body = scenario.to_json
|
|
28
|
+
|
|
29
|
+
signed_request = Yoti::SignedRequest.new(
|
|
30
|
+
unsigned,
|
|
31
|
+
endpoint,
|
|
32
|
+
scenario
|
|
33
|
+
).sign
|
|
34
|
+
|
|
35
|
+
response = Net::HTTP.start(
|
|
36
|
+
uri.hostname,
|
|
37
|
+
uri.port,
|
|
38
|
+
use_ssl: true
|
|
39
|
+
) do |http|
|
|
40
|
+
http.request signed_request
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
create_share_url_parse_response response
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.create_share_url_parse_response(response)
|
|
47
|
+
if response.code.to_i < 200 || response.code.to_i >= 300
|
|
48
|
+
case response.code
|
|
49
|
+
when '400'
|
|
50
|
+
raise InvalidDataError
|
|
51
|
+
when '404'
|
|
52
|
+
raise ApplicationNotFoundError
|
|
53
|
+
else
|
|
54
|
+
raise UnknownHTTPError, response.code
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
Share.new JSON.parse response.body
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class InvalidDataError < StandardError
|
|
62
|
+
def initialize(msg = 'JSON is incorrect, contains invalid data')
|
|
63
|
+
super
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
class ApplicationNotFoundError < StandardError
|
|
68
|
+
def initialize(msg = 'Application was not found')
|
|
69
|
+
super
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
class UnknownHTTPError < StandardError
|
|
74
|
+
def initialize(code = nil, msg = 'Unknown HTTP Error')
|
|
75
|
+
msg = "#{msg}: #{code}" unless code.nil?
|
|
76
|
+
super msg
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -15,6 +15,7 @@ module Yoti
|
|
|
15
15
|
|
|
16
16
|
def request
|
|
17
17
|
yoti_request = Yoti::Request.new
|
|
18
|
+
yoti_request.add_header('X-Yoti-Auth-Key', Yoti::SSL.auth_key_from_pem)
|
|
18
19
|
yoti_request.encrypted_connect_token = @encrypted_connect_token
|
|
19
20
|
yoti_request.http_method = 'GET'
|
|
20
21
|
yoti_request.endpoint = 'profile'
|
data/lib/yoti/http/request.rb
CHANGED
|
@@ -14,6 +14,15 @@ module Yoti
|
|
|
14
14
|
# @return [Hash] the body sent with the request
|
|
15
15
|
attr_accessor :payload
|
|
16
16
|
|
|
17
|
+
def initialize
|
|
18
|
+
@headers = {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Adds a HTTP header to the request
|
|
22
|
+
def add_header(header, value)
|
|
23
|
+
@headers[header] = value
|
|
24
|
+
end
|
|
25
|
+
|
|
17
26
|
# Makes a HTTP request after signing the headers
|
|
18
27
|
# @return [Hash] the body from the HTTP request
|
|
19
28
|
def body
|
|
@@ -51,6 +60,10 @@ module Yoti
|
|
|
51
60
|
raise RequestError, "Request method not allowed: #{@http_method}"
|
|
52
61
|
end
|
|
53
62
|
|
|
63
|
+
@headers.each do |header, value|
|
|
64
|
+
http_req[header] = value
|
|
65
|
+
end
|
|
66
|
+
|
|
54
67
|
http_req
|
|
55
68
|
end
|
|
56
69
|
|
|
@@ -11,12 +11,9 @@ module Yoti
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def sign
|
|
14
|
-
@http_req['X-Yoti-Auth-Key'] = @auth_key
|
|
15
14
|
@http_req['X-Yoti-Auth-Digest'] = message_signature
|
|
16
15
|
@http_req['X-Yoti-SDK'] = Yoti.configuration.sdk_identifier
|
|
17
16
|
@http_req['X-Yoti-SDK-Version'] = "#{Yoti.configuration.sdk_identifier}-#{Yoti::VERSION}"
|
|
18
|
-
@http_req['Content-Type'] = 'application/json'
|
|
19
|
-
@http_req['Accept'] = 'application/json'
|
|
20
17
|
@http_req
|
|
21
18
|
end
|
|
22
19
|
|
data/lib/yoti/protobuf/main.rb
CHANGED
|
@@ -6,6 +6,9 @@ require 'json'
|
|
|
6
6
|
require_relative 'attrpubapi/List_pb.rb'
|
|
7
7
|
require_relative 'compubapi/EncryptedData_pb.rb'
|
|
8
8
|
require_relative 'compubapi/SignedTimestamp_pb.rb'
|
|
9
|
+
require_relative 'sharepubapi/ExtraData_pb.rb'
|
|
10
|
+
require_relative 'sharepubapi/IssuingAttributes_pb.rb'
|
|
11
|
+
require_relative 'sharepubapi/ThirdPartyAttribute_pb.rb'
|
|
9
12
|
|
|
10
13
|
module Yoti
|
|
11
14
|
module Protobuf
|
|
@@ -40,12 +43,20 @@ module Yoti
|
|
|
40
43
|
decipher_profile(receipt['profile_content'], receipt['wrapped_receipt_key'])
|
|
41
44
|
end
|
|
42
45
|
|
|
46
|
+
def extra_data(receipt)
|
|
47
|
+
return nil unless valid_receipt?(receipt)
|
|
48
|
+
|
|
49
|
+
decipher_profile(receipt['extra_data'], receipt['wrapped_receipt_key']) if receipt['extra_data']
|
|
50
|
+
end
|
|
51
|
+
|
|
43
52
|
def attribute_list(data)
|
|
44
53
|
Yoti::Protobuf::Attrpubapi::AttributeList.decode(data)
|
|
45
54
|
end
|
|
46
55
|
|
|
47
56
|
def value_based_on_attribute_name(value, attr_name)
|
|
48
57
|
case attr_name
|
|
58
|
+
when Yoti::Attribute::DOCUMENT_DETAILS
|
|
59
|
+
Yoti::DocumentDetails.new(value)
|
|
49
60
|
when Yoti::Attribute::DOCUMENT_IMAGES
|
|
50
61
|
raise(TypeError, 'Document Images could not be decoded') unless value.is_a?(Yoti::MultiValue)
|
|
51
62
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
2
|
+
# source: DataEntry.proto
|
|
3
|
+
|
|
4
|
+
require 'google/protobuf'
|
|
5
|
+
|
|
6
|
+
Google::Protobuf::DescriptorPool.generated_pool.build do
|
|
7
|
+
add_message "sharepubapi_v1.DataEntry" do
|
|
8
|
+
optional :type, :enum, 1, "sharepubapi_v1.DataEntry.Type"
|
|
9
|
+
optional :value, :bytes, 2
|
|
10
|
+
end
|
|
11
|
+
add_enum "sharepubapi_v1.DataEntry.Type" do
|
|
12
|
+
value :UNDEFINED, 0
|
|
13
|
+
value :INVOICE, 1
|
|
14
|
+
value :PAYMENT_TRANSACTION, 2
|
|
15
|
+
value :LOCATION, 3
|
|
16
|
+
value :TRANSACTION, 4
|
|
17
|
+
value :AGE_VERIFICATION_SECRET, 5
|
|
18
|
+
value :THIRD_PARTY_ATTRIBUTE, 6
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
module Yoti
|
|
23
|
+
module Protobuf
|
|
24
|
+
module Sharepubapi
|
|
25
|
+
DataEntry = Google::Protobuf::DescriptorPool.generated_pool.lookup("sharepubapi_v1.DataEntry").msgclass
|
|
26
|
+
DataEntry::Type = Google::Protobuf::DescriptorPool.generated_pool.lookup("sharepubapi_v1.DataEntry.Type").enummodule
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
2
|
+
# source: ExtraData.proto
|
|
3
|
+
|
|
4
|
+
require 'google/protobuf'
|
|
5
|
+
|
|
6
|
+
require_relative 'DataEntry_pb'
|
|
7
|
+
Google::Protobuf::DescriptorPool.generated_pool.build do
|
|
8
|
+
add_message "sharepubapi_v1.ExtraData" do
|
|
9
|
+
repeated :list, :message, 1, "sharepubapi_v1.DataEntry"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module Yoti
|
|
14
|
+
module Protobuf
|
|
15
|
+
module Sharepubapi
|
|
16
|
+
ExtraData = Google::Protobuf::DescriptorPool.generated_pool.lookup("sharepubapi_v1.ExtraData").msgclass
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
2
|
+
# source: IssuingAttributes.proto
|
|
3
|
+
|
|
4
|
+
require 'google/protobuf'
|
|
5
|
+
|
|
6
|
+
Google::Protobuf::DescriptorPool.generated_pool.build do
|
|
7
|
+
add_message "sharepubapi_v1.IssuingAttributes" do
|
|
8
|
+
optional :expiry_date, :string, 1
|
|
9
|
+
repeated :definitions, :message, 2, "sharepubapi_v1.Definition"
|
|
10
|
+
end
|
|
11
|
+
add_message "sharepubapi_v1.Definition" do
|
|
12
|
+
optional :name, :string, 1
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module Yoti
|
|
17
|
+
module Protobuf
|
|
18
|
+
module Sharepubapi
|
|
19
|
+
IssuingAttributes = Google::Protobuf::DescriptorPool.generated_pool.lookup("sharepubapi_v1.IssuingAttributes").msgclass
|
|
20
|
+
Definition = Google::Protobuf::DescriptorPool.generated_pool.lookup("sharepubapi_v1.Definition").msgclass
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
2
|
+
# source: ThirdPartyAttribute.proto
|
|
3
|
+
|
|
4
|
+
require 'google/protobuf'
|
|
5
|
+
|
|
6
|
+
require_relative 'IssuingAttributes_pb'
|
|
7
|
+
Google::Protobuf::DescriptorPool.generated_pool.build do
|
|
8
|
+
add_message "sharepubapi_v1.ThirdPartyAttribute" do
|
|
9
|
+
optional :issuance_token, :bytes, 1
|
|
10
|
+
optional :issuing_attributes, :message, 2, "sharepubapi_v1.IssuingAttributes"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module Yoti
|
|
15
|
+
module Protobuf
|
|
16
|
+
module Sharepubapi
|
|
17
|
+
ThirdPartyAttribute = Google::Protobuf::DescriptorPool.generated_pool.lookup("sharepubapi_v1.ThirdPartyAttribute").msgclass
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/yoti/sandbox.rb
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sandbox
|
|
4
|
+
# Anchor describes an anchor on a Sandbox Attribute
|
|
5
|
+
class Anchor
|
|
6
|
+
attr_reader :type
|
|
7
|
+
attr_reader :value
|
|
8
|
+
attr_reader :sub_type
|
|
9
|
+
attr_reader :timestamp
|
|
10
|
+
|
|
11
|
+
def initialize(type:, value:, sub_type: '', timestamp: Time.now)
|
|
12
|
+
@type = type
|
|
13
|
+
@value = value
|
|
14
|
+
@sub_type = sub_type
|
|
15
|
+
@timestamp = timestamp
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def as_json(*_args)
|
|
19
|
+
{
|
|
20
|
+
type: @type,
|
|
21
|
+
value: @value,
|
|
22
|
+
sub_type: @sub_type,
|
|
23
|
+
timestamp: @timestamp.to_i
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_json(*args)
|
|
28
|
+
as_json.to_json(*args)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.source(value, sub_type: '', timestamp: Time.now)
|
|
32
|
+
Anchor.new(
|
|
33
|
+
type: 'SOURCE',
|
|
34
|
+
value: value,
|
|
35
|
+
sub_type: sub_type,
|
|
36
|
+
timestamp: timestamp
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.verifier(value, sub_type: '', timestamp: Time.now)
|
|
41
|
+
Anchor.new(
|
|
42
|
+
type: 'VERIFIER',
|
|
43
|
+
value: value,
|
|
44
|
+
sub_type: sub_type,
|
|
45
|
+
timestamp: timestamp
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|