diaspora_federation 0.0.12 → 0.0.13
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/diaspora_federation.rb +103 -18
- data/lib/diaspora_federation/discovery/discovery.rb +1 -1
- data/lib/diaspora_federation/discovery/h_card.rb +4 -5
- data/lib/diaspora_federation/discovery/host_meta.rb +1 -1
- data/lib/diaspora_federation/discovery/web_finger.rb +8 -8
- data/lib/diaspora_federation/discovery/xrd_document.rb +6 -7
- data/lib/diaspora_federation/entities.rb +21 -10
- data/lib/diaspora_federation/entities/account_deletion.rb +7 -3
- data/lib/diaspora_federation/entities/comment.rb +13 -10
- data/lib/diaspora_federation/entities/contact.rb +29 -0
- data/lib/diaspora_federation/entities/conversation.rb +5 -6
- data/lib/diaspora_federation/entities/like.rb +10 -18
- data/lib/diaspora_federation/entities/message.rb +6 -12
- data/lib/diaspora_federation/entities/participation.rb +8 -16
- data/lib/diaspora_federation/entities/person.rb +6 -2
- data/lib/diaspora_federation/entities/photo.rb +3 -3
- data/lib/diaspora_federation/entities/poll_participation.rb +6 -12
- data/lib/diaspora_federation/entities/post.rb +37 -0
- data/lib/diaspora_federation/entities/profile.rb +7 -3
- data/lib/diaspora_federation/entities/relayable.rb +169 -65
- data/lib/diaspora_federation/entities/relayable_retraction.rb +33 -32
- data/lib/diaspora_federation/entities/request.rb +20 -6
- data/lib/diaspora_federation/entities/reshare.rb +5 -27
- data/lib/diaspora_federation/entities/retraction.rb +6 -6
- data/lib/diaspora_federation/entities/signed_retraction.rb +32 -26
- data/lib/diaspora_federation/entities/status_message.rb +2 -22
- data/lib/diaspora_federation/entity.rb +137 -38
- data/lib/diaspora_federation/federation.rb +9 -0
- data/lib/diaspora_federation/federation/fetcher.rb +26 -0
- data/lib/diaspora_federation/federation/receiver.rb +41 -0
- data/lib/diaspora_federation/federation/receiver/abstract_receiver.rb +35 -0
- data/lib/diaspora_federation/federation/receiver/exceptions.rb +13 -0
- data/lib/diaspora_federation/federation/receiver/private.rb +15 -0
- data/lib/diaspora_federation/federation/receiver/public.rb +9 -0
- data/lib/diaspora_federation/federation/sender.rb +33 -0
- data/lib/diaspora_federation/federation/sender/hydra_wrapper.rb +92 -0
- data/lib/diaspora_federation/{fetcher.rb → http_client.rb} +6 -6
- data/lib/diaspora_federation/properties_dsl.rb +51 -14
- data/lib/diaspora_federation/salmon.rb +2 -1
- data/lib/diaspora_federation/salmon/aes.rb +1 -1
- data/lib/diaspora_federation/salmon/encrypted_magic_envelope.rb +61 -0
- data/lib/diaspora_federation/salmon/encrypted_slap.rb +69 -50
- data/lib/diaspora_federation/salmon/exceptions.rb +8 -14
- data/lib/diaspora_federation/salmon/magic_envelope.rb +80 -39
- data/lib/diaspora_federation/salmon/slap.rb +20 -51
- data/lib/diaspora_federation/salmon/xml_payload.rb +5 -104
- data/lib/diaspora_federation/validators.rb +22 -16
- data/lib/diaspora_federation/validators/account_deletion_validator.rb +1 -1
- data/lib/diaspora_federation/validators/comment_validator.rb +0 -4
- data/lib/diaspora_federation/validators/contact_validator.rb +13 -0
- data/lib/diaspora_federation/validators/conversation_validator.rb +2 -2
- data/lib/diaspora_federation/validators/like_validator.rb +1 -3
- data/lib/diaspora_federation/validators/message_validator.rb +0 -4
- data/lib/diaspora_federation/validators/participation_validator.rb +1 -5
- data/lib/diaspora_federation/validators/person_validator.rb +1 -1
- data/lib/diaspora_federation/validators/photo_validator.rb +2 -2
- data/lib/diaspora_federation/validators/poll_participation_validator.rb +0 -4
- data/lib/diaspora_federation/validators/profile_validator.rb +1 -1
- data/lib/diaspora_federation/validators/relayable_retraction_validator.rb +1 -1
- data/lib/diaspora_federation/validators/relayable_validator.rb +2 -0
- data/lib/diaspora_federation/validators/request_validator.rb +3 -2
- data/lib/diaspora_federation/validators/reshare_validator.rb +3 -3
- data/lib/diaspora_federation/validators/retraction_validator.rb +2 -2
- data/lib/diaspora_federation/validators/rules/guid.rb +16 -7
- data/lib/diaspora_federation/validators/signed_retraction_validator.rb +1 -1
- data/lib/diaspora_federation/validators/status_message_validator.rb +2 -2
- data/lib/diaspora_federation/version.rb +1 -1
- metadata +20 -11
- data/lib/diaspora_federation/receiver.rb +0 -28
- data/lib/diaspora_federation/receiver/private.rb +0 -19
- data/lib/diaspora_federation/receiver/public.rb +0 -13
- data/lib/diaspora_federation/signing.rb +0 -56
@@ -0,0 +1,26 @@
|
|
1
|
+
module DiasporaFederation
|
2
|
+
module Federation
|
3
|
+
# this module is for fetching entities from other pods
|
4
|
+
module Fetcher
|
5
|
+
# fetches a public entity from a remote pod
|
6
|
+
# @param [String] author the diaspora ID of the author of the entity
|
7
|
+
# @param [Symbol, String] entity_type snake_case version of the entity class
|
8
|
+
# @param [String] guid guid of the entity to fetch
|
9
|
+
def self.fetch_public(author, entity_type, guid)
|
10
|
+
url = DiasporaFederation.callbacks.trigger(:fetch_person_url_to, author, "/fetch/#{entity_type}/#{guid}")
|
11
|
+
response = HttpClient.get(url)
|
12
|
+
raise "Failed to fetch #{url}: #{response.status}" unless response.success?
|
13
|
+
|
14
|
+
magic_env_xml = Nokogiri::XML::Document.parse(response.body).root
|
15
|
+
magic_env = Salmon::MagicEnvelope.unenvelop(magic_env_xml)
|
16
|
+
Receiver::Public.new(magic_env).receive
|
17
|
+
rescue => e
|
18
|
+
raise NotFetchable, "Failed to fetch #{entity_type}:#{guid} from #{author}: #{e.class}: #{e.message}"
|
19
|
+
end
|
20
|
+
|
21
|
+
# Raised, if the entity is not fetchable
|
22
|
+
class NotFetchable < RuntimeError
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
module DiasporaFederation
|
2
|
+
module Federation
|
3
|
+
# this module is for parse and receive entities.
|
4
|
+
module Receiver
|
5
|
+
# receive a public message
|
6
|
+
# @param [String] data message to receive
|
7
|
+
# @param [Boolean] legacy use old slap parser
|
8
|
+
def self.receive_public(data, legacy=false)
|
9
|
+
magic_env = if legacy
|
10
|
+
Salmon::Slap.from_xml(data)
|
11
|
+
else
|
12
|
+
magic_env_xml = Nokogiri::XML::Document.parse(data).root
|
13
|
+
Salmon::MagicEnvelope.unenvelop(magic_env_xml)
|
14
|
+
end
|
15
|
+
Public.new(magic_env).receive
|
16
|
+
end
|
17
|
+
|
18
|
+
# receive a private message
|
19
|
+
# @param [String] data message to receive
|
20
|
+
# @param [OpenSSL::PKey::RSA] recipient_private_key recipient private key to decrypt the message
|
21
|
+
# @param [Object] recipient_id the identifier to persist the entity for the correct user,
|
22
|
+
# see +receive_entity+ callback
|
23
|
+
# @param [Boolean] legacy use old slap parser
|
24
|
+
def self.receive_private(data, recipient_private_key, recipient_id, legacy=false)
|
25
|
+
raise ArgumentError, "no recipient key provided" unless recipient_private_key.instance_of?(OpenSSL::PKey::RSA)
|
26
|
+
magic_env = if legacy
|
27
|
+
Salmon::EncryptedSlap.from_xml(data, recipient_private_key)
|
28
|
+
else
|
29
|
+
magic_env_xml = Salmon::EncryptedMagicEnvelope.decrypt(data, recipient_private_key)
|
30
|
+
Salmon::MagicEnvelope.unenvelop(magic_env_xml)
|
31
|
+
end
|
32
|
+
Private.new(magic_env, recipient_id).receive
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
require "diaspora_federation/federation/receiver/exceptions"
|
39
|
+
require "diaspora_federation/federation/receiver/abstract_receiver"
|
40
|
+
require "diaspora_federation/federation/receiver/public"
|
41
|
+
require "diaspora_federation/federation/receiver/private"
|
@@ -0,0 +1,35 @@
|
|
1
|
+
module DiasporaFederation
|
2
|
+
module Federation
|
3
|
+
module Receiver
|
4
|
+
# common functionality for receivers
|
5
|
+
class AbstractReceiver
|
6
|
+
# create a new receiver
|
7
|
+
# @param [MagicEnvelope] magic_envelope the received magic envelope
|
8
|
+
# @param [Object] recipient_id the identifier of the recipient of a private message
|
9
|
+
def initialize(magic_envelope, recipient_id=nil)
|
10
|
+
@entity = magic_envelope.payload
|
11
|
+
@sender = magic_envelope.sender
|
12
|
+
@recipient_id = recipient_id
|
13
|
+
end
|
14
|
+
|
15
|
+
# validate and receive the entity
|
16
|
+
def receive
|
17
|
+
validate
|
18
|
+
DiasporaFederation.callbacks.trigger(:receive_entity, entity, recipient_id)
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
attr_reader :entity, :sender, :recipient_id
|
24
|
+
|
25
|
+
def validate
|
26
|
+
raise InvalidSender unless sender_valid?
|
27
|
+
end
|
28
|
+
|
29
|
+
def sender_valid?
|
30
|
+
sender == entity.author # TODO: handle sender of relayables
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module DiasporaFederation
|
2
|
+
module Federation
|
3
|
+
module Receiver
|
4
|
+
# Raised, if the sender of the {Salmon::MagicEnvelope} is not allowed to send the entity.
|
5
|
+
class InvalidSender < RuntimeError
|
6
|
+
end
|
7
|
+
|
8
|
+
# Raised, if receiving a private message without recipient.
|
9
|
+
class RecipientRequired < RuntimeError
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module DiasporaFederation
|
2
|
+
module Federation
|
3
|
+
# Federation logic to send messages to other pods
|
4
|
+
module Sender
|
5
|
+
# Send a public message to all urls
|
6
|
+
#
|
7
|
+
# @param [String] sender_id sender diaspora-ID
|
8
|
+
# @param [String] obj_str object string representation for logging (e.g. type@guid)
|
9
|
+
# @param [Array<String>] urls receive-urls from pods
|
10
|
+
# @param [String] xml salmon-xml
|
11
|
+
# @return [Array<String>] url to retry
|
12
|
+
def self.public(sender_id, obj_str, urls, xml)
|
13
|
+
hydra = HydraWrapper.new(sender_id, obj_str)
|
14
|
+
urls.each {|url| hydra.insert_job(url, xml) }
|
15
|
+
hydra.send
|
16
|
+
end
|
17
|
+
|
18
|
+
# Send a private message to receive-urls
|
19
|
+
#
|
20
|
+
# @param [String] sender_id sender diaspora-ID
|
21
|
+
# @param [String] obj_str object string representation for logging (e.g. type@guid)
|
22
|
+
# @param [Hash] targets Hash with receive-urls (key) of peoples with encrypted salmon-xml for them (value)
|
23
|
+
# @return [Hash] targets to retry
|
24
|
+
def self.private(sender_id, obj_str, targets)
|
25
|
+
hydra = HydraWrapper.new(sender_id, obj_str)
|
26
|
+
targets.each {|url, xml| hydra.insert_job(url, xml) }
|
27
|
+
Hash[hydra.send.map {|url| [url, targets[url]] }]
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
require "diaspora_federation/federation/sender/hydra_wrapper"
|
@@ -0,0 +1,92 @@
|
|
1
|
+
module DiasporaFederation
|
2
|
+
module Federation
|
3
|
+
module Sender
|
4
|
+
# A wrapper for [Typhoeus::Hydra]
|
5
|
+
#
|
6
|
+
# Uses parallel http requests to send out the salmon-messages
|
7
|
+
class HydraWrapper
|
8
|
+
include Logging
|
9
|
+
|
10
|
+
# Hydra default opts
|
11
|
+
# @return [Hash] hydra opts
|
12
|
+
def self.hydra_opts
|
13
|
+
@hydra_opts ||= {
|
14
|
+
maxredirs: DiasporaFederation.http_redirect_limit,
|
15
|
+
timeout: DiasporaFederation.http_timeout,
|
16
|
+
method: :post,
|
17
|
+
verbose: DiasporaFederation.http_verbose,
|
18
|
+
cainfo: DiasporaFederation.certificate_authorities,
|
19
|
+
headers: {
|
20
|
+
"Expect" => "",
|
21
|
+
"Transfer-Encoding" => "",
|
22
|
+
"User-Agent" => DiasporaFederation.http_user_agent
|
23
|
+
}
|
24
|
+
}
|
25
|
+
end
|
26
|
+
|
27
|
+
# Create a new instance for a message
|
28
|
+
#
|
29
|
+
# @param [String] sender_id sender diaspora-ID
|
30
|
+
# @param [String] obj_str object string representation for logging (e.g. type@guid)
|
31
|
+
def initialize(sender_id, obj_str)
|
32
|
+
@sender_id = sender_id
|
33
|
+
@obj_str = obj_str
|
34
|
+
@urls_to_retry = []
|
35
|
+
end
|
36
|
+
|
37
|
+
# Prepares and inserts job into the hydra queue
|
38
|
+
# @param [String] url the receive-url for the xml
|
39
|
+
# @param [String] xml xml salmon message
|
40
|
+
def insert_job(url, xml)
|
41
|
+
request = Typhoeus::Request.new(url, HydraWrapper.hydra_opts.merge(body: {xml: xml}))
|
42
|
+
prepare_request(request)
|
43
|
+
hydra.queue(request)
|
44
|
+
end
|
45
|
+
|
46
|
+
# Sends all queued messages
|
47
|
+
# @return [Array<String>] urls to retry
|
48
|
+
def send
|
49
|
+
hydra.run
|
50
|
+
@urls_to_retry
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
# @return [Typhoeus::Hydra] hydra
|
56
|
+
def hydra
|
57
|
+
@hydra ||= Typhoeus::Hydra.new(max_concurrency: DiasporaFederation.http_concurrency)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Logic for after complete
|
61
|
+
# @param [Typhoeus::Request] request
|
62
|
+
def prepare_request(request)
|
63
|
+
request.on_complete do |response|
|
64
|
+
DiasporaFederation.callbacks.trigger(:update_pod, pod_url(response.effective_url), status(response))
|
65
|
+
|
66
|
+
success = response.success?
|
67
|
+
log_line = "success=#{success} sender=#{@sender_id} obj=#{@obj_str} url=#{response.effective_url} " \
|
68
|
+
"message=#{response.return_code} code=#{response.response_code} time=#{response.total_time}"
|
69
|
+
if success
|
70
|
+
logger.info(log_line)
|
71
|
+
else
|
72
|
+
logger.warn(log_line)
|
73
|
+
|
74
|
+
@urls_to_retry << request.url
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Get the pod root-url from the send-url
|
80
|
+
# @param [String] url
|
81
|
+
# @return [String] pod root-url
|
82
|
+
def pod_url(url)
|
83
|
+
URI.parse(url).tap {|uri| uri.path = "/" }.to_s
|
84
|
+
end
|
85
|
+
|
86
|
+
def status(res)
|
87
|
+
res.return_code == :ok ? res.response_code : res.return_code
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -2,11 +2,11 @@ require "faraday"
|
|
2
2
|
require "faraday_middleware/response/follow_redirects"
|
3
3
|
|
4
4
|
module DiasporaFederation
|
5
|
-
# A wrapper for {https://github.com/lostisland/faraday Faraday}
|
6
|
-
# fetching
|
5
|
+
# A wrapper for {https://github.com/lostisland/faraday Faraday}.
|
7
6
|
#
|
8
7
|
# @see Discovery::Discovery
|
9
|
-
|
8
|
+
# @see Federation::Fetcher
|
9
|
+
class HttpClient
|
10
10
|
# Perform a GET request
|
11
11
|
#
|
12
12
|
# @param [String] uri the URI
|
@@ -25,16 +25,16 @@ module DiasporaFederation
|
|
25
25
|
|
26
26
|
def self.create_default_connection
|
27
27
|
options = {
|
28
|
-
request: {timeout:
|
28
|
+
request: {timeout: DiasporaFederation.http_timeout},
|
29
29
|
ssl: {ca_file: DiasporaFederation.certificate_authorities}
|
30
30
|
}
|
31
31
|
|
32
32
|
@connection = Faraday::Connection.new(options) do |builder|
|
33
|
-
builder.use FaradayMiddleware::FollowRedirects, limit:
|
33
|
+
builder.use FaradayMiddleware::FollowRedirects, limit: DiasporaFederation.http_redirect_limit
|
34
34
|
builder.adapter Faraday.default_adapter
|
35
35
|
end
|
36
36
|
|
37
|
-
@connection.headers["User-Agent"] =
|
37
|
+
@connection.headers["User-Agent"] = DiasporaFederation.http_user_agent
|
38
38
|
end
|
39
39
|
private_class_method :create_default_connection
|
40
40
|
end
|
@@ -7,12 +7,13 @@ module DiasporaFederation
|
|
7
7
|
# property :optional, default: false
|
8
8
|
# property :dynamic_default, default: -> { Time.now }
|
9
9
|
# property :another_prop, xml_name: :another_name
|
10
|
+
# property :original_prop, alias: :alias_prop
|
10
11
|
# entity :nested, NestedEntity
|
11
12
|
# entity :multiple, [OtherEntity]
|
12
13
|
module PropertiesDSL
|
13
|
-
# @return [
|
14
|
+
# @return [Hash] hash of declared entity properties
|
14
15
|
def class_props
|
15
|
-
@class_props ||=
|
16
|
+
@class_props ||= {}
|
16
17
|
end
|
17
18
|
|
18
19
|
# Define a generic (string-type) property
|
@@ -42,32 +43,48 @@ module DiasporaFederation
|
|
42
43
|
# Return array of missing required property names
|
43
44
|
# @return [Array<Symbol>] missing required property names
|
44
45
|
def missing_props(args)
|
45
|
-
|
46
|
+
class_props.keys - default_props.keys - args.keys
|
46
47
|
end
|
47
48
|
|
48
49
|
# Return a new hash of default values, with dynamic values
|
49
50
|
# resolved on each call
|
50
51
|
# @return [Hash] default values
|
51
52
|
def default_values
|
52
|
-
default_props.each_with_object({}) {
|
53
|
+
default_props.each_with_object({}) {|(name, prop), hash|
|
53
54
|
hash[name] = prop.respond_to?(:call) ? prop.call : prop
|
54
55
|
}
|
55
56
|
end
|
56
57
|
|
57
|
-
#
|
58
|
-
# @return [
|
59
|
-
def
|
60
|
-
|
58
|
+
# @param [Hash] data entity data
|
59
|
+
# @return [Hash] hash with resolved aliases
|
60
|
+
def resolv_aliases(data)
|
61
|
+
Hash[data.map {|name, value|
|
62
|
+
if class_prop_aliases.has_key? name
|
63
|
+
prop_name = class_prop_aliases[name]
|
64
|
+
raise InvalidData, "only use '#{name}' OR '#{prop_name}'" if data.has_key? prop_name
|
65
|
+
[prop_name, value]
|
66
|
+
else
|
67
|
+
[name, value]
|
68
|
+
end
|
69
|
+
}]
|
70
|
+
end
|
71
|
+
|
72
|
+
# @return [Symbol] alias for the xml-generation/parsing
|
73
|
+
# @deprecated
|
74
|
+
def xml_names
|
75
|
+
@xml_names ||= {}
|
61
76
|
end
|
62
77
|
|
63
|
-
#
|
64
|
-
# @
|
65
|
-
|
66
|
-
|
78
|
+
# finds a property by +xml_name+ or +name+
|
79
|
+
# @param [String] xml_name name of the property from the received xml
|
80
|
+
# @return [Hash] the property data
|
81
|
+
def find_property_for_xml_name(xml_name)
|
82
|
+
class_props.keys.find {|name| name.to_s == xml_name || xml_names[name].to_s == xml_name }
|
67
83
|
end
|
68
84
|
|
69
85
|
private
|
70
86
|
|
87
|
+
# @deprecated
|
71
88
|
def determine_xml_name(name, type, opts={})
|
72
89
|
raise ArgumentError, "xml_name is not supported for nested entities" if type != String && opts.has_key?(:xml_name)
|
73
90
|
|
@@ -88,10 +105,13 @@ module DiasporaFederation
|
|
88
105
|
def define_property(name, type, opts={})
|
89
106
|
raise InvalidName unless name_valid?(name)
|
90
107
|
|
91
|
-
class_props
|
108
|
+
class_props[name] = type
|
92
109
|
default_props[name] = opts[:default] if opts.has_key? :default
|
110
|
+
xml_names[name] = determine_xml_name(name, type, opts)
|
93
111
|
|
94
112
|
instance_eval { attr_reader name }
|
113
|
+
|
114
|
+
define_alias(name, opts[:alias]) if opts.has_key? :alias
|
95
115
|
end
|
96
116
|
|
97
117
|
# checks if the name is a +Symbol+ or a +String+
|
@@ -105,7 +125,7 @@ module DiasporaFederation
|
|
105
125
|
# @param [Class] type the type to check
|
106
126
|
# @return [Boolean]
|
107
127
|
def type_valid?(type)
|
108
|
-
[type].flatten.all? {
|
128
|
+
[type].flatten.all? {|type|
|
109
129
|
type.respond_to?(:ancestors) && type.ancestors.include?(Entity)
|
110
130
|
}
|
111
131
|
end
|
@@ -114,6 +134,19 @@ module DiasporaFederation
|
|
114
134
|
@default_props ||= {}
|
115
135
|
end
|
116
136
|
|
137
|
+
# Returns all alias mappings
|
138
|
+
# @return [Hash] alias properties
|
139
|
+
def class_prop_aliases
|
140
|
+
@class_prop_aliases ||= {}
|
141
|
+
end
|
142
|
+
|
143
|
+
# @param [Symbol] name property name
|
144
|
+
# @param [Symbol] alias_name alias name
|
145
|
+
def define_alias(name, alias_name)
|
146
|
+
class_prop_aliases[alias_name] = name
|
147
|
+
instance_eval { alias_method alias_name, name }
|
148
|
+
end
|
149
|
+
|
117
150
|
# Raised, if the name is of an unexpected type
|
118
151
|
class InvalidName < RuntimeError
|
119
152
|
end
|
@@ -121,5 +154,9 @@ module DiasporaFederation
|
|
121
154
|
# Raised, if the type is of an unexpected type
|
122
155
|
class InvalidType < RuntimeError
|
123
156
|
end
|
157
|
+
|
158
|
+
# Raised, if the data contains property twice (with name AND alias)
|
159
|
+
class InvalidData < RuntimeError
|
160
|
+
end
|
124
161
|
end
|
125
162
|
end
|