rmega 0.0.6 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +108 -55
- data/lib/rmega/crypto/aes.rb +5 -3
- data/lib/rmega/crypto/aes_ctr.rb +5 -2
- data/lib/rmega/crypto/crypto.rb +13 -8
- data/lib/rmega/crypto/rsa.rb +3 -1
- data/lib/rmega/downloader.rb +13 -16
- data/lib/rmega/loggable.rb +9 -2
- data/lib/rmega/nodes/deletable.rb +16 -0
- data/lib/rmega/nodes/expandable.rb +64 -0
- data/lib/rmega/nodes/factory.rb +41 -0
- data/lib/rmega/nodes/file.rb +39 -0
- data/lib/rmega/nodes/folder.rb +30 -0
- data/lib/rmega/nodes/inbox.rb +8 -0
- data/lib/rmega/nodes/node.rb +58 -100
- data/lib/rmega/nodes/root.rb +12 -0
- data/lib/rmega/nodes/trash.rb +18 -0
- data/lib/rmega/nodes/traversable.rb +26 -0
- data/lib/rmega/options.rb +16 -0
- data/lib/rmega/pool.rb +4 -6
- data/lib/rmega/progress.rb +34 -0
- data/lib/rmega/{api_request_error.rb → request_error.rb} +7 -10
- data/lib/rmega/session.rb +20 -31
- data/lib/rmega/storage.rb +19 -68
- data/lib/rmega/uploader.rb +58 -0
- data/lib/rmega/utils.rb +35 -29
- data/lib/rmega/version.rb +1 -1
- data/lib/rmega.rb +8 -53
- data/rmega.gemspec +2 -2
- data/spec/integration/{file_operations_spec.rb → file_download_spec.rb} +4 -8
- data/spec/integration/folder_operations_spec.rb +27 -15
- data/spec/integration/login_spec.rb +6 -5
- data/spec/integration_spec_helper.rb +12 -4
- data/spec/rmega/lib/utils_spec.rb +0 -7
- data/spec/spec_helper.rb +4 -11
- metadata +18 -24
- data/lib/rmega/nodes/file_node.rb +0 -27
- data/lib/rmega/nodes/folder_node.rb +0 -29
data/lib/rmega/nodes/node.rb
CHANGED
@@ -1,122 +1,80 @@
|
|
1
|
+
require 'rmega/loggable'
|
2
|
+
require 'rmega/utils'
|
3
|
+
require 'rmega/crypto/crypto'
|
4
|
+
require 'rmega/nodes/traversable'
|
5
|
+
|
1
6
|
module Rmega
|
2
|
-
|
3
|
-
|
7
|
+
module Nodes
|
8
|
+
class Node
|
9
|
+
include Loggable
|
10
|
+
include Traversable
|
11
|
+
|
12
|
+
attr_reader :data, :session
|
4
13
|
|
5
|
-
|
6
|
-
@session = session
|
14
|
+
delegate :storage, :request, :to => :session
|
7
15
|
|
8
|
-
|
9
|
-
@
|
10
|
-
@public_url = data
|
11
|
-
else
|
16
|
+
def initialize(session, data)
|
17
|
+
@session = session
|
12
18
|
@data = data
|
13
19
|
end
|
14
|
-
end
|
15
|
-
|
16
|
-
def self.fabricate session, data
|
17
|
-
type_name = mega_url?(data) ? :file : type_by_number(data['t'])
|
18
|
-
node_class = Rmega.const_get("#{type_name}_node".camelize) rescue nil
|
19
|
-
node_class ||= Rmega::Node
|
20
|
-
node_class.new session, data
|
21
|
-
end
|
22
|
-
|
23
|
-
def self.types
|
24
|
-
{file: 0, folder: 1, root: 2, inbox: 3, trash: 4}
|
25
|
-
end
|
26
|
-
|
27
|
-
def self.type_by_number number
|
28
|
-
founded_type = types.find { |k, v| number == v }
|
29
|
-
founded_type.first if founded_type
|
30
|
-
end
|
31
|
-
|
32
|
-
def self.mega_url? url
|
33
|
-
!!(url.to_s =~ /^https:\/\/mega\.co\.nz\/#!.*$/i)
|
34
|
-
end
|
35
|
-
|
36
|
-
def logger
|
37
|
-
Rmega.logger
|
38
|
-
end
|
39
|
-
|
40
|
-
# Member actions
|
41
|
-
|
42
|
-
def public_url
|
43
|
-
return @public_url if @public_url
|
44
|
-
return nil if type != :file
|
45
|
-
b64_dec_key = Utils.a32_to_base64 decrypted_file_key[0..7]
|
46
|
-
"https://mega.co.nz/#!#{public_handle}!#{b64_dec_key}"
|
47
|
-
end
|
48
|
-
|
49
|
-
def trash
|
50
|
-
trash_node_public_handle = storage.trash_node.public_handle
|
51
|
-
request a: 'm', n: handle, t: trash_node_public_handle
|
52
|
-
end
|
53
|
-
|
54
|
-
|
55
|
-
# Delegate to session
|
56
|
-
|
57
|
-
delegate :storage, :request, :to => :session
|
58
|
-
|
59
|
-
|
60
|
-
# Other methods
|
61
20
|
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
@public_handle ||= request(a: 'l', n: handle)
|
69
|
-
end
|
21
|
+
def public_url
|
22
|
+
@public_url ||= begin
|
23
|
+
b64_dec_key = Utils.a32_to_base64 decrypted_file_key[0..7]
|
24
|
+
"https://mega.co.nz/#!#{public_handle}!#{b64_dec_key}"
|
25
|
+
end
|
26
|
+
end
|
70
27
|
|
71
|
-
|
72
|
-
|
73
|
-
|
28
|
+
def public_url=(url)
|
29
|
+
@public_url = url
|
30
|
+
end
|
74
31
|
|
75
|
-
|
76
|
-
|
77
|
-
|
32
|
+
def public_handle
|
33
|
+
@public_handle ||= request(a: 'l', n: handle)
|
34
|
+
end
|
78
35
|
|
79
|
-
|
80
|
-
|
81
|
-
|
36
|
+
def handle
|
37
|
+
data['h']
|
38
|
+
end
|
82
39
|
|
83
|
-
|
84
|
-
|
85
|
-
|
40
|
+
def parent_handle
|
41
|
+
data['p']
|
42
|
+
end
|
86
43
|
|
87
|
-
|
88
|
-
|
89
|
-
|
44
|
+
def owner_key
|
45
|
+
data['k'].split(':').first
|
46
|
+
end
|
90
47
|
|
91
|
-
|
92
|
-
|
93
|
-
|
48
|
+
def name
|
49
|
+
return attributes['n'] if attributes
|
50
|
+
end
|
94
51
|
|
95
|
-
|
96
|
-
|
97
|
-
Crypto.decrypt_key session.master_key, Utils.base64_to_a32(file_key)
|
98
|
-
else
|
99
|
-
Utils.base64_to_a32 public_url.split('!').last
|
52
|
+
def file_key
|
53
|
+
data['k'].split(':').last
|
100
54
|
end
|
101
|
-
end
|
102
55
|
|
103
|
-
|
104
|
-
|
105
|
-
|
56
|
+
def decrypted_file_key
|
57
|
+
if data['k']
|
58
|
+
Crypto.decrypt_key session.master_key, Utils.base64_to_a32(file_key)
|
59
|
+
else
|
60
|
+
Utils.base64_to_a32 public_url.split('!').last
|
61
|
+
end
|
62
|
+
end
|
106
63
|
|
107
|
-
|
108
|
-
|
109
|
-
return nil unless can_decrypt_attributes?
|
110
|
-
Crypto.decrypt_attributes decrypted_file_key, (data['a'] || data['at'])
|
64
|
+
def can_decrypt_attributes?
|
65
|
+
!data['u'] or data['u'] == owner_key
|
111
66
|
end
|
112
|
-
end
|
113
67
|
|
114
|
-
|
115
|
-
|
116
|
-
|
68
|
+
def attributes
|
69
|
+
@attributes ||= begin
|
70
|
+
return nil unless can_decrypt_attributes?
|
71
|
+
Crypto.decrypt_attributes decrypted_file_key, (data['a'] || data['at'])
|
72
|
+
end
|
73
|
+
end
|
117
74
|
|
118
|
-
|
119
|
-
|
75
|
+
def type
|
76
|
+
Factory.type(data['t'])
|
77
|
+
end
|
120
78
|
end
|
121
79
|
end
|
122
80
|
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
require 'rmega/nodes/node'
|
2
|
+
require 'rmega/nodes/traversable'
|
3
|
+
|
4
|
+
module Rmega
|
5
|
+
module Nodes
|
6
|
+
class Trash < Node
|
7
|
+
include Traversable
|
8
|
+
|
9
|
+
def empty!
|
10
|
+
children.each do |node|
|
11
|
+
node.delete if node.respond_to?(:delete)
|
12
|
+
end
|
13
|
+
|
14
|
+
empty?
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Rmega
|
2
|
+
module Nodes
|
3
|
+
module Traversable
|
4
|
+
def children
|
5
|
+
storage.nodes.select { |node| node.parent_handle == handle }
|
6
|
+
end
|
7
|
+
|
8
|
+
def folders
|
9
|
+
children.select { |node| node.type == :folder }
|
10
|
+
end
|
11
|
+
|
12
|
+
def files
|
13
|
+
children.select { |node| node.type == :file }
|
14
|
+
end
|
15
|
+
|
16
|
+
def parent
|
17
|
+
return unless parent_handle
|
18
|
+
storage.nodes.find { |node| node.handle == parent_handle }
|
19
|
+
end
|
20
|
+
|
21
|
+
def empty?
|
22
|
+
children.size == 0
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'ostruct'
|
2
|
+
|
3
|
+
module Rmega
|
4
|
+
def self.default_options
|
5
|
+
{
|
6
|
+
show_progress: true,
|
7
|
+
upload_timeout: 120,
|
8
|
+
api_request_timeout: 20,
|
9
|
+
api_url: 'https://eu.api.mega.co.nz/cs'
|
10
|
+
}
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.options
|
14
|
+
@options ||= OpenStruct.new(default_options)
|
15
|
+
end
|
16
|
+
end
|
data/lib/rmega/pool.rb
CHANGED
@@ -1,13 +1,11 @@
|
|
1
1
|
require 'thread'
|
2
2
|
|
3
|
-
|
4
|
-
# Helper to create a Pool instance.
|
5
|
-
def self.pool(max)
|
6
|
-
Pool.new(max)
|
7
|
-
end
|
8
|
-
|
3
|
+
module Rmega
|
9
4
|
class Pool
|
5
|
+
MAX = 5
|
6
|
+
|
10
7
|
def initialize(max)
|
8
|
+
max ||= MAX
|
11
9
|
Thread.abort_on_exception = true
|
12
10
|
@mutex = Mutex.new
|
13
11
|
@threads = Array.new(max)
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module Rmega
|
2
|
+
class Progress
|
3
|
+
|
4
|
+
def initialize(params)
|
5
|
+
@filesize = params[:filesize]
|
6
|
+
@verb = params[:verb].capitalize
|
7
|
+
@progress = 0
|
8
|
+
|
9
|
+
render
|
10
|
+
end
|
11
|
+
|
12
|
+
def render
|
13
|
+
percentage = (100.0 * @progress / @filesize).round(2)
|
14
|
+
message = "#{@verb} in progress #{format_bytes(@progress)} of #{format_bytes(@filesize)} (#{percentage}%)"
|
15
|
+
rtrn = "\n" if @filesize == @progress
|
16
|
+
|
17
|
+
print "\r#{' '*(message.size + 15)}\r#{message}#{rtrn}"
|
18
|
+
end
|
19
|
+
|
20
|
+
def increment(bytes)
|
21
|
+
@progress += bytes
|
22
|
+
|
23
|
+
render
|
24
|
+
end
|
25
|
+
|
26
|
+
def format_bytes(bytes, round = 2)
|
27
|
+
units = ['bytes', 'kb', 'MB', 'GB', 'TB', 'PB']
|
28
|
+
e = (bytes == 0 ? 0 : Math.log(bytes)) / Math.log(1024)
|
29
|
+
value = bytes.to_f / (1024 ** e.floor)
|
30
|
+
|
31
|
+
"#{value.round(round)}#{units[e]}"
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -1,18 +1,15 @@
|
|
1
1
|
module Rmega
|
2
|
-
class
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
@code = error_code
|
7
|
-
error_description = self.class.all_messages[@code]
|
8
|
-
super "Received error code #{@code}. #{error_description}".strip
|
2
|
+
class RequestError < StandardError
|
3
|
+
def initialize(error_code)
|
4
|
+
message = self.class.errors[error_code]
|
5
|
+
super("Error #{error_code}: #{message}")
|
9
6
|
end
|
10
7
|
|
11
|
-
def self.
|
12
|
-
(
|
8
|
+
def self.error_code?(number)
|
9
|
+
number.respond_to?(:to_i) and number.to_i < 0
|
13
10
|
end
|
14
11
|
|
15
|
-
def self.
|
12
|
+
def self.errors
|
16
13
|
{
|
17
14
|
-1 => 'An internal error has occurred. Please submit a bug report, detailing the exact circumstances in which this error occurred.',
|
18
15
|
-2 => 'You have passed invalid arguments to this command.',
|
data/lib/rmega/session.rb
CHANGED
@@ -1,63 +1,52 @@
|
|
1
|
+
require 'rmega/storage'
|
2
|
+
require 'rmega/request_error'
|
3
|
+
require 'rmega/crypto/crypto'
|
4
|
+
require 'rmega/utils'
|
5
|
+
|
1
6
|
module Rmega
|
7
|
+
def self.login(email, password)
|
8
|
+
Session.new(email, password).storage
|
9
|
+
end
|
10
|
+
|
2
11
|
class Session
|
12
|
+
include Loggable
|
13
|
+
|
3
14
|
attr_accessor :email, :request_id, :sid, :master_key
|
4
15
|
|
5
|
-
def initialize
|
16
|
+
def initialize(email, password)
|
6
17
|
self.email = email
|
7
18
|
self.request_id = random_request_id
|
8
19
|
|
9
|
-
login
|
20
|
+
login(password)
|
10
21
|
end
|
11
22
|
|
12
|
-
def logger
|
13
|
-
Rmega.logger
|
14
|
-
end
|
15
|
-
|
16
|
-
|
17
|
-
# Delegate to Rmega.options
|
18
|
-
|
19
23
|
def options
|
20
24
|
Rmega.options
|
21
25
|
end
|
22
26
|
|
23
|
-
|
24
|
-
options.api_request_timeout
|
25
|
-
end
|
26
|
-
|
27
|
-
def api_url
|
28
|
-
options.api_url
|
29
|
-
end
|
30
|
-
|
31
|
-
|
32
|
-
# Cache the Storage class
|
27
|
+
delegate :api_url, :api_request_timeout, to: :options
|
33
28
|
|
34
29
|
def storage
|
35
|
-
@storage ||= Storage.new
|
30
|
+
@storage ||= Storage.new(self)
|
36
31
|
end
|
37
32
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
def login password_str
|
42
|
-
uh = Crypto.stringhash Crypto.prepare_key_pw(password_str), email
|
33
|
+
def login(password)
|
34
|
+
uh = Crypto.stringhash Crypto.prepare_key_pw(password), email
|
43
35
|
resp = request a: 'us', user: email, uh: uh
|
44
36
|
|
45
37
|
# Decrypt the master key
|
46
|
-
encrypted_key = Crypto.prepare_key_pw
|
38
|
+
encrypted_key = Crypto.prepare_key_pw password
|
47
39
|
self.master_key = Crypto.decrypt_key encrypted_key, Utils.base64_to_a32(resp['k'])
|
48
40
|
|
49
41
|
# Generate the session id
|
50
42
|
self.sid = Crypto.decrypt_sid master_key, resp['csid'], resp['privk']
|
51
43
|
end
|
52
44
|
|
53
|
-
|
54
|
-
# Api requests methods
|
55
|
-
|
56
45
|
def random_request_id
|
57
46
|
rand(1E7..1E9).to_i
|
58
47
|
end
|
59
48
|
|
60
|
-
def request
|
49
|
+
def request(body)
|
61
50
|
self.request_id += 1
|
62
51
|
url = "#{api_url}?id=#{request_id}"
|
63
52
|
url << "&sid=#{sid}" if sid
|
@@ -66,7 +55,7 @@ module Rmega
|
|
66
55
|
response = HTTPClient.new.post url, [body].to_json, timeout: api_request_timeout
|
67
56
|
logger.debug "#{response.code}\n#{response.body}"
|
68
57
|
resp = JSON.parse(response.body).first
|
69
|
-
raise
|
58
|
+
raise RequestError.new(resp) if RequestError.error_code?(resp)
|
70
59
|
resp
|
71
60
|
end
|
72
61
|
end
|
data/lib/rmega/storage.rb
CHANGED
@@ -1,19 +1,17 @@
|
|
1
|
+
require 'rmega/utils'
|
2
|
+
require 'rmega/crypto/crypto'
|
3
|
+
require 'rmega/nodes/factory'
|
4
|
+
|
1
5
|
module Rmega
|
2
6
|
class Storage
|
7
|
+
include Loggable
|
3
8
|
|
4
9
|
attr_reader :session
|
5
10
|
|
6
|
-
def initialize
|
11
|
+
def initialize(session)
|
7
12
|
@session = session
|
8
13
|
end
|
9
14
|
|
10
|
-
def logger
|
11
|
-
Rmega.logger
|
12
|
-
end
|
13
|
-
|
14
|
-
|
15
|
-
# Quota-related methods
|
16
|
-
|
17
15
|
def used_space
|
18
16
|
quota['cstrg']
|
19
17
|
end
|
@@ -26,81 +24,34 @@ module Rmega
|
|
26
24
|
session.request a: 'uq', strg: 1
|
27
25
|
end
|
28
26
|
|
29
|
-
|
30
|
-
# Nodes management
|
31
|
-
|
32
27
|
def nodes
|
33
|
-
|
34
|
-
|
35
|
-
end
|
36
|
-
|
37
|
-
def nodes_by_type type
|
38
|
-
nodes.select { |n| n.type == type }
|
39
|
-
end
|
40
|
-
|
41
|
-
def nodes_by_name name_regexp
|
42
|
-
nodes.select do |node|
|
43
|
-
node.name and node.name =~ name_regexp
|
44
|
-
end
|
45
|
-
end
|
46
|
-
|
47
|
-
def trash_node
|
48
|
-
@trash ||= nodes_by_type(:trash).first
|
28
|
+
result = session.request(a: 'f', c: 1)
|
29
|
+
result['f'].map { |node_data| Nodes::Factory.build(session, node_data) }
|
49
30
|
end
|
50
31
|
|
51
|
-
def
|
52
|
-
@
|
32
|
+
def trash
|
33
|
+
@trash ||= nodes.find { |n| n.type == :trash }
|
53
34
|
end
|
54
35
|
|
55
|
-
def
|
56
|
-
|
36
|
+
def root
|
37
|
+
@root_node ||= nodes.find { |n| n.type == :root }
|
57
38
|
end
|
58
39
|
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
def self.chunks size
|
63
|
-
list = {}
|
64
|
-
p = 0
|
65
|
-
pp = 0
|
66
|
-
i = 1
|
67
|
-
|
68
|
-
while i <= 8 and p < size - (i * 0x20000)
|
69
|
-
list[p] = i * 0x20000
|
70
|
-
pp = p
|
71
|
-
p += list[p]
|
72
|
-
i += 1
|
73
|
-
end
|
74
|
-
|
75
|
-
while p < size
|
76
|
-
list[p] = 0x100000
|
77
|
-
pp = p
|
78
|
-
p += list[p]
|
79
|
-
end
|
80
|
-
|
81
|
-
if size - pp > 0
|
82
|
-
list[pp] = size - pp
|
83
|
-
end
|
84
|
-
list
|
85
|
-
end
|
86
|
-
|
87
|
-
def download public_url, path
|
88
|
-
Node.fabricate(session, public_url).download(path)
|
40
|
+
def download(public_url, path)
|
41
|
+
Nodes::Factory.build_from_url(session, public_url).download(path)
|
89
42
|
end
|
90
43
|
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
def upload_url filesize
|
44
|
+
# TODO: refactor upload part
|
45
|
+
def upload_url(filesize)
|
95
46
|
session.request(a: 'u', s: filesize)['p']
|
96
47
|
end
|
97
48
|
|
98
|
-
def upload_chunk
|
49
|
+
def upload_chunk(url, start, chunk)
|
99
50
|
response = HTTPClient.new.post "#{url}/#{start}", chunk, timeout: Rmega.options.upload_timeout
|
100
51
|
response.body
|
101
52
|
end
|
102
53
|
|
103
|
-
def upload
|
54
|
+
def upload(local_path, parent_node = root)
|
104
55
|
local_path = File.expand_path local_path
|
105
56
|
filesize = File.size local_path
|
106
57
|
upld_url = upload_url filesize
|
@@ -114,7 +65,7 @@ module Rmega
|
|
114
65
|
|
115
66
|
Utils.show_progress :upload, filesize
|
116
67
|
|
117
|
-
|
68
|
+
Utils.chunks(filesize).each do |chunk_start, chunk_size|
|
118
69
|
buffer = local_file.read chunk_size
|
119
70
|
|
120
71
|
# TODO: should be (chunk_start/0x1000000000) >>> 0, (chunk_start/0x10) >>> 0
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'rmega/loggable'
|
2
|
+
require 'rmega/utils'
|
3
|
+
require 'rmega/pool'
|
4
|
+
require 'rmega/progress'
|
5
|
+
|
6
|
+
module Rmega
|
7
|
+
class Uploader
|
8
|
+
include Loggable
|
9
|
+
|
10
|
+
attr_reader :pool, :base_url, :filesize, :local_path, :last_result
|
11
|
+
|
12
|
+
def initialize(params)
|
13
|
+
@pool = Pool.new(params[:threads])
|
14
|
+
@filesize = params[:filesize]
|
15
|
+
@base_url = params[:base_url]
|
16
|
+
@local_path = params[:local_path]
|
17
|
+
@last_result = nil
|
18
|
+
end
|
19
|
+
|
20
|
+
def upload_chunk(start, buffer)
|
21
|
+
size = buffer.length
|
22
|
+
stop = start + size - 1
|
23
|
+
url = "#{base_url}/#{start}-#{stop}"
|
24
|
+
|
25
|
+
HTTPClient.new.post(url, buffer).body
|
26
|
+
end
|
27
|
+
|
28
|
+
def read_chunk(start, size)
|
29
|
+
@local_file.seek(start)
|
30
|
+
@local_file.read(size)
|
31
|
+
end
|
32
|
+
|
33
|
+
def chunks
|
34
|
+
Utils.chunks(filesize)
|
35
|
+
end
|
36
|
+
|
37
|
+
def upload(&block)
|
38
|
+
@local_file = ::File.open(local_path, 'rb')
|
39
|
+
|
40
|
+
progress = Progress.new(filesize: filesize, verb: 'upload')
|
41
|
+
|
42
|
+
chunks.each do |start, size|
|
43
|
+
|
44
|
+
pool.defer do
|
45
|
+
clean_buffer = pool.syncronize { read_chunk(start, size) }
|
46
|
+
encrypted_buffer = yield(start, clean_buffer)
|
47
|
+
@last_result = upload_chunk(start, encrypted_buffer)
|
48
|
+
progress.increment(buffer.size)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
pool.wait_done
|
53
|
+
pool.shutdown
|
54
|
+
ensure
|
55
|
+
@local_file.close
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|