rmega 0.0.6 → 0.1.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.
- 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
|