condo 1.0.4 → 1.0.6
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 +7 -0
- data/README.textile +133 -133
- data/app/assets/javascripts/condo.js +9 -6
- data/app/assets/javascripts/condo/amazon.js +403 -406
- data/app/assets/javascripts/condo/condo.js +184 -0
- data/app/assets/javascripts/condo/config.js +69 -80
- data/app/assets/javascripts/condo/google.js +338 -255
- data/app/assets/javascripts/condo/md5/hash.worker.emulator.js +23 -23
- data/app/assets/javascripts/condo/md5/hash.worker.js +11 -11
- data/app/assets/javascripts/condo/md5/hasher.js +119 -100
- data/app/assets/javascripts/condo/md5/spark-md5.js +276 -161
- data/app/assets/javascripts/condo/rackspace.js +326 -329
- data/app/assets/javascripts/condo/{abstract-md5.js.erb → services/abstract-md5.js.erb} +86 -93
- data/app/assets/javascripts/condo/{base64.js → services/base64.js} +2 -10
- data/app/assets/javascripts/condo/services/broadcaster.js +26 -0
- data/app/assets/javascripts/condo/services/uploader.js +302 -0
- data/app/assets/javascripts/core/core.js +4 -0
- data/app/assets/javascripts/core/services/1-safe-apply.js +17 -0
- data/app/assets/javascripts/core/services/2-messaging.js +171 -0
- data/lib/condo.rb +269 -269
- data/lib/condo/configuration.rb +137 -139
- data/lib/condo/errors.rb +8 -8
- data/lib/condo/strata/amazon_s3.rb +301 -301
- data/lib/condo/strata/google_cloud_storage.rb +315 -314
- data/lib/condo/strata/rackspace_cloud_files.rb +245 -223
- data/lib/condo/version.rb +1 -1
- metadata +21 -44
- data/app/assets/javascripts/condo/broadcaster.js +0 -60
- data/app/assets/javascripts/condo/controller.js +0 -194
- data/app/assets/javascripts/condo/uploader.js +0 -310
- data/test/dummy/db/test.sqlite3 +0 -0
- data/test/dummy/log/test.log +0 -25
@@ -1,314 +1,315 @@
|
|
1
|
-
module Condo; end
|
2
|
-
module Condo::Strata; end
|
3
|
-
|
4
|
-
|
5
|
-
class Fog::Storage::Google::Real
|
6
|
-
def condo_request(*args)
|
7
|
-
request(*args)
|
8
|
-
end
|
9
|
-
end
|
10
|
-
|
11
|
-
|
12
|
-
class Condo::Strata::GoogleCloudStorage
|
13
|
-
|
14
|
-
def initialize(options)
|
15
|
-
@options = {
|
16
|
-
:name => :GoogleCloudStorage,
|
17
|
-
:location => :na, # US or Europe, set at bucket creation time
|
18
|
-
:fog => {
|
19
|
-
:provider => 'Google',
|
20
|
-
:google_storage_access_key_id => options[:fog_access_id] || options[:access_id],
|
21
|
-
:google_storage_secret_access_key => options[:fog_secret_key] || options[:secret_key]
|
22
|
-
},
|
23
|
-
:api => 1
|
24
|
-
}.merge!(options)
|
25
|
-
|
26
|
-
|
27
|
-
raise ArgumentError, 'Google Access ID missing' if @options[:access_id].nil?
|
28
|
-
raise ArgumentError, 'Google Secret Key missing' if @options[:secret_key].nil?
|
29
|
-
|
30
|
-
if @options[:api] == 2
|
31
|
-
@options[:secret_key] = OpenSSL::PKey::RSA.new(@options[:secret_key])
|
32
|
-
end
|
33
|
-
|
34
|
-
@options[:location] = @options[:location].to_sym
|
35
|
-
end
|
36
|
-
|
37
|
-
|
38
|
-
#
|
39
|
-
# Enable CORS on a bucket for a domain
|
40
|
-
#
|
41
|
-
def enable_cors(bucket, origin = '*')
|
42
|
-
data =
|
43
|
-
<<-DATA
|
44
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
45
|
-
<CorsConfig>
|
46
|
-
<Cors>
|
47
|
-
<Origins>
|
48
|
-
<Origin>#{origin}</Origin>
|
49
|
-
</Origins>
|
50
|
-
<Methods>
|
51
|
-
<Method>GET</Method>
|
52
|
-
<Method>HEAD</Method>
|
53
|
-
<Method>POST</Method>
|
54
|
-
<Method>PUT</Method>
|
55
|
-
</Methods>
|
56
|
-
<ResponseHeaders>
|
57
|
-
<ResponseHeader>origin</ResponseHeader>
|
58
|
-
<ResponseHeader>content-md5</ResponseHeader>
|
59
|
-
<ResponseHeader>authorization</ResponseHeader>
|
60
|
-
<ResponseHeader>x-goog-date</ResponseHeader>
|
61
|
-
<ResponseHeader>x-goog-acl</ResponseHeader>
|
62
|
-
<ResponseHeader>content-type</ResponseHeader>
|
63
|
-
<ResponseHeader>accept</ResponseHeader>
|
64
|
-
<ResponseHeader>x-goog-api-version</ResponseHeader>
|
65
|
-
<ResponseHeader>x-goog-resumable</ResponseHeader>
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
</
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
:
|
76
|
-
:
|
77
|
-
:
|
78
|
-
:
|
79
|
-
:
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
#
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
:
|
103
|
-
:
|
104
|
-
:
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
#
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
#
|
118
|
-
#
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
:
|
125
|
-
:
|
126
|
-
:
|
127
|
-
:
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
#
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
options[:object_options]
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
options[:object_options][:
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
#
|
196
|
-
#
|
197
|
-
|
198
|
-
|
199
|
-
:type => :
|
200
|
-
:signature => sign_request(options)
|
201
|
-
}
|
202
|
-
end
|
203
|
-
|
204
|
-
|
205
|
-
#
|
206
|
-
# Returns the requests for uploading parts and completing a resumable upload
|
207
|
-
#
|
208
|
-
def set_part(options)
|
209
|
-
resp = get_parts(options)
|
210
|
-
resp[:type] = :
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
return file.
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
#
|
240
|
-
#
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
#
|
249
|
-
#
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
#
|
266
|
-
#
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
288
|
-
#
|
289
|
-
#
|
290
|
-
|
291
|
-
|
292
|
-
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
url
|
300
|
-
|
301
|
-
|
302
|
-
|
303
|
-
#
|
304
|
-
#
|
305
|
-
|
306
|
-
|
307
|
-
:
|
308
|
-
:
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
1
|
+
module Condo; end
|
2
|
+
module Condo::Strata; end
|
3
|
+
|
4
|
+
|
5
|
+
class Fog::Storage::Google::Real
|
6
|
+
def condo_request(*args)
|
7
|
+
request(*args)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
|
12
|
+
class Condo::Strata::GoogleCloudStorage
|
13
|
+
|
14
|
+
def initialize(options)
|
15
|
+
@options = {
|
16
|
+
:name => :GoogleCloudStorage,
|
17
|
+
:location => :na, # US or Europe, set at bucket creation time
|
18
|
+
:fog => {
|
19
|
+
:provider => 'Google',
|
20
|
+
:google_storage_access_key_id => options[:fog_access_id] || options[:access_id],
|
21
|
+
:google_storage_secret_access_key => options[:fog_secret_key] || options[:secret_key]
|
22
|
+
},
|
23
|
+
:api => 1
|
24
|
+
}.merge!(options)
|
25
|
+
|
26
|
+
|
27
|
+
raise ArgumentError, 'Google Access ID missing' if @options[:access_id].nil?
|
28
|
+
raise ArgumentError, 'Google Secret Key missing' if @options[:secret_key].nil?
|
29
|
+
|
30
|
+
if @options[:api] == 2
|
31
|
+
@options[:secret_key] = OpenSSL::PKey::RSA.new(@options[:secret_key])
|
32
|
+
end
|
33
|
+
|
34
|
+
@options[:location] = @options[:location].to_sym
|
35
|
+
end
|
36
|
+
|
37
|
+
|
38
|
+
#
|
39
|
+
# Enable CORS on a bucket for a domain
|
40
|
+
#
|
41
|
+
def enable_cors(bucket, origin = '*')
|
42
|
+
data =
|
43
|
+
<<-DATA
|
44
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
45
|
+
<CorsConfig>
|
46
|
+
<Cors>
|
47
|
+
<Origins>
|
48
|
+
<Origin>#{origin}</Origin>
|
49
|
+
</Origins>
|
50
|
+
<Methods>
|
51
|
+
<Method>GET</Method>
|
52
|
+
<Method>HEAD</Method>
|
53
|
+
<Method>POST</Method>
|
54
|
+
<Method>PUT</Method>
|
55
|
+
</Methods>
|
56
|
+
<ResponseHeaders>
|
57
|
+
<ResponseHeader>origin</ResponseHeader>
|
58
|
+
<ResponseHeader>content-md5</ResponseHeader>
|
59
|
+
<ResponseHeader>authorization</ResponseHeader>
|
60
|
+
<ResponseHeader>x-goog-date</ResponseHeader>
|
61
|
+
<ResponseHeader>x-goog-acl</ResponseHeader>
|
62
|
+
<ResponseHeader>content-type</ResponseHeader>
|
63
|
+
<ResponseHeader>accept</ResponseHeader>
|
64
|
+
<ResponseHeader>x-goog-api-version</ResponseHeader>
|
65
|
+
<ResponseHeader>x-goog-resumable</ResponseHeader>
|
66
|
+
<ResponseHeader>content-range</ResponseHeader>
|
67
|
+
<ResponseHeader>x-requested-with</ResponseHeader>
|
68
|
+
</ResponseHeaders>
|
69
|
+
<MaxAgeSec>1800</MaxAgeSec>
|
70
|
+
</Cors>
|
71
|
+
</CorsConfig>
|
72
|
+
DATA
|
73
|
+
|
74
|
+
fog_connection.condo_request(
|
75
|
+
:expects => 200,
|
76
|
+
:body => data,
|
77
|
+
:method => 'PUT',
|
78
|
+
:headers => {},
|
79
|
+
:host => "#{bucket}.storage.googleapis.com",
|
80
|
+
:idempotent => true,
|
81
|
+
:path => '?cors' # There is an issue with Fog where this isn't included as a canonical_resource
|
82
|
+
)
|
83
|
+
end
|
84
|
+
|
85
|
+
|
86
|
+
def name
|
87
|
+
@options[:name]
|
88
|
+
end
|
89
|
+
|
90
|
+
|
91
|
+
def location
|
92
|
+
@options[:location]
|
93
|
+
end
|
94
|
+
|
95
|
+
|
96
|
+
#
|
97
|
+
# Create a signed URL for accessing a private file
|
98
|
+
#
|
99
|
+
def get_object(options)
|
100
|
+
options = {}.merge!(options) # Need to deep copy here
|
101
|
+
options[:object_options] = {
|
102
|
+
:expires => 5.minutes.from_now,
|
103
|
+
:verb => :get,
|
104
|
+
:headers => {},
|
105
|
+
:parameters => {},
|
106
|
+
:protocol => :https
|
107
|
+
}.merge!(options[:object_options] || {})
|
108
|
+
options.merge!(@options)
|
109
|
+
|
110
|
+
#
|
111
|
+
# provide the signed request
|
112
|
+
#
|
113
|
+
sign_request(options)[:url]
|
114
|
+
end
|
115
|
+
|
116
|
+
|
117
|
+
#
|
118
|
+
# Creates a new upload request (either single shot or multi-part)
|
119
|
+
# => Passed: bucket_name, object_key, object_options, file_size
|
120
|
+
#
|
121
|
+
def new_upload(options)
|
122
|
+
options = {}.merge!(options) # Need to deep copy here
|
123
|
+
options[:object_options] = {
|
124
|
+
:permissions => :private,
|
125
|
+
:expires => 5.minutes.from_now,
|
126
|
+
:verb => :put, # put for direct uploads
|
127
|
+
:headers => {},
|
128
|
+
:parameters => {},
|
129
|
+
:protocol => :https
|
130
|
+
}.merge!(options[:object_options] || {})
|
131
|
+
options.merge!(@options)
|
132
|
+
|
133
|
+
|
134
|
+
options[:object_options][:headers]['x-goog-api-version'] = @options[:api]
|
135
|
+
|
136
|
+
if options[:object_options][:headers]['x-goog-acl'].nil?
|
137
|
+
options[:object_options][:headers]['x-goog-acl'] = case options[:object_options][:permissions]
|
138
|
+
when :public
|
139
|
+
:'public-read'
|
140
|
+
else
|
141
|
+
:private
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
options[:object_options][:headers]['Content-Type'] = 'binary/octet-stream' if options[:object_options][:headers]['Content-Type'].nil?
|
146
|
+
|
147
|
+
|
148
|
+
#
|
149
|
+
# Decide what type of request is being sent
|
150
|
+
#
|
151
|
+
if options[:file_size] > 1.megabytes
|
152
|
+
# Resumables may not support the md5 header at this time - have to compare ETag and fail on the client side
|
153
|
+
options[:object_options][:verb] = :post
|
154
|
+
options[:object_options][:headers]['x-goog-resumable'] = 'start'
|
155
|
+
return {
|
156
|
+
:signature => sign_request(options),
|
157
|
+
:type => :chunked_upload # triggers resumable
|
158
|
+
}
|
159
|
+
else
|
160
|
+
options[:object_options][:headers]['Content-Md5'] = options[:file_id] if options[:file_id].present? && options[:object_options][:headers]['Content-Md5'].nil?
|
161
|
+
return {
|
162
|
+
:signature => sign_request(options),
|
163
|
+
:type => :direct_upload
|
164
|
+
}
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
|
169
|
+
#
|
170
|
+
# Creates a request for the byte we were up to
|
171
|
+
#
|
172
|
+
def get_parts(options, setting_parts = false)
|
173
|
+
options[:object_options] = {
|
174
|
+
:expires => 5.minutes.from_now,
|
175
|
+
:verb => :put, # put for direct uploads
|
176
|
+
:headers => {},
|
177
|
+
:parameters => {},
|
178
|
+
:protocol => :https
|
179
|
+
}.merge!(options[:object_options] || {})
|
180
|
+
options.merge!(@options)
|
181
|
+
|
182
|
+
#
|
183
|
+
# Set the upload and request the range of bytes we are after
|
184
|
+
#
|
185
|
+
if setting_parts
|
186
|
+
options[:object_options][:headers]['Content-Md5'] = options[:file_id] if options[:file_id].present? && options[:object_options][:headers]['Content-Md5'].nil?
|
187
|
+
options[:object_options][:headers]['Content-Range'] = "bytes #{options[:part]}-#{options[:file_size] - 1}/#{options[:file_size]}"
|
188
|
+
else
|
189
|
+
options[:object_options][:headers]['Content-Range'] = "bytes */#{options[:file_size]}"
|
190
|
+
end
|
191
|
+
options[:object_options][:headers]['x-goog-api-version'] = @options[:api]
|
192
|
+
options[:object_options][:parameters]['upload_id'] = options[:resumable_id]
|
193
|
+
|
194
|
+
#
|
195
|
+
# provide the signed request
|
196
|
+
#
|
197
|
+
{
|
198
|
+
:expected => 308,
|
199
|
+
:type => :status,
|
200
|
+
:signature => sign_request(options)
|
201
|
+
}
|
202
|
+
end
|
203
|
+
|
204
|
+
|
205
|
+
#
|
206
|
+
# Returns the requests for uploading parts and completing a resumable upload
|
207
|
+
#
|
208
|
+
def set_part(options)
|
209
|
+
resp = get_parts(options, true)
|
210
|
+
resp[:type] = :resume_upload
|
211
|
+
resp[:type] = :resume_upload
|
212
|
+
return resp
|
213
|
+
end
|
214
|
+
|
215
|
+
|
216
|
+
def fog_connection
|
217
|
+
@fog = @fog || Fog::Storage.new(@options[:fog])
|
218
|
+
return @fog
|
219
|
+
end
|
220
|
+
|
221
|
+
|
222
|
+
def destroy(upload)
|
223
|
+
connection = fog_connection
|
224
|
+
directory = connection.directories.get(upload.bucket_name) # it is assumed this exists - if not then the upload wouldn't have taken place
|
225
|
+
file = directory.files.get(upload.object_key) # NOTE:: I only assume this works with resumables... should look into it
|
226
|
+
|
227
|
+
return true if file.nil?
|
228
|
+
return file.destroy
|
229
|
+
end
|
230
|
+
|
231
|
+
|
232
|
+
|
233
|
+
protected
|
234
|
+
|
235
|
+
|
236
|
+
|
237
|
+
def sign_request(options)
|
238
|
+
|
239
|
+
#
|
240
|
+
# Build base URL
|
241
|
+
#
|
242
|
+
verb = options[:object_options][:verb].to_s.upcase.to_sym
|
243
|
+
options[:object_options][:expires] = options[:object_options][:expires].utc.to_i
|
244
|
+
|
245
|
+
url = "/#{options[:object_key]}"
|
246
|
+
|
247
|
+
|
248
|
+
#
|
249
|
+
# Add signed request params
|
250
|
+
#
|
251
|
+
other_params = ''
|
252
|
+
signed_params = '?'
|
253
|
+
(options[:object_options][:parameters] || {}).each do |key, value|
|
254
|
+
if ['acl', 'cors', 'location', 'logging', 'requestPayment', 'torrent', 'versions', 'versioning'].include?(key)
|
255
|
+
signed_params << "#{key}&"
|
256
|
+
else
|
257
|
+
other_params << (value.empty? ? "#{key}&" : "#{key}=#{value}&")
|
258
|
+
end
|
259
|
+
end
|
260
|
+
signed_params.chop!
|
261
|
+
|
262
|
+
url << signed_params
|
263
|
+
|
264
|
+
|
265
|
+
#
|
266
|
+
# Build a request signature
|
267
|
+
#
|
268
|
+
signature = "#{verb}\n#{options[:object_options][:headers]['Content-Md5']}\n#{options[:object_options][:headers]['Content-Type']}\n#{options[:object_options][:expires]}\n"
|
269
|
+
if verb != :GET
|
270
|
+
options[:object_options][:headers]['x-goog-date'] ||= Time.now.utc.httpdate
|
271
|
+
|
272
|
+
google_headers, canonical_google_headers = {}, '' # Copied from https://github.com/fog/fog/blob/master/lib/fog/google/storage.rb
|
273
|
+
for key, value in options[:object_options][:headers]
|
274
|
+
if key[0..6] == 'x-goog-'
|
275
|
+
google_headers[key] = value
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
google_headers = google_headers.sort {|x, y| x[0] <=> y[0]}
|
280
|
+
for key, value in google_headers
|
281
|
+
signature << "#{key}:#{value}\n"
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
signature << "/#{options[:bucket_name]}#{url}"
|
286
|
+
|
287
|
+
|
288
|
+
#
|
289
|
+
# Encode the request signature
|
290
|
+
#
|
291
|
+
if @options[:api] == 1
|
292
|
+
signature = Base64.encode64(OpenSSL::HMAC.digest(OpenSSL::Digest::Digest.new('sha1'), @options[:secret_key], signature)).gsub("\n","")
|
293
|
+
options[:object_options][:headers]['Authorization'] = "GOOG1 #{@options[:access_id]}:#{signature}"
|
294
|
+
else
|
295
|
+
signature = Base64.encode64(@options[:secret_key].sign(OpenSSL::Digest::SHA256.new, signature)).gsub("\n","")
|
296
|
+
end
|
297
|
+
|
298
|
+
|
299
|
+
url += signed_params.present? ? '&' : '?'
|
300
|
+
url = "#{options[:object_options][:protocol]}://#{options[:bucket_name]}.storage.googleapis.com#{url}#{other_params}GoogleAccessId=#{@options[:access_id]}&Expires=#{options[:object_options][:expires]}&Signature=#{CGI::escape(signature)}"
|
301
|
+
|
302
|
+
|
303
|
+
#
|
304
|
+
# Finish building the request
|
305
|
+
#
|
306
|
+
return {
|
307
|
+
:verb => options[:object_options][:verb].to_s.upcase,
|
308
|
+
:url => url,
|
309
|
+
:headers => options[:object_options][:headers]
|
310
|
+
}
|
311
|
+
end
|
312
|
+
|
313
|
+
|
314
|
+
end
|
315
|
+
|