aspera-cli 4.10.0 → 4.12.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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/BUGS.md +19 -0
- data/CHANGELOG.md +528 -0
- data/CONTRIBUTING.md +143 -0
- data/README.md +977 -589
- data/bin/ascli +4 -4
- data/bin/asession +12 -12
- data/docs/test_env.conf +29 -19
- data/examples/aoc.rb +6 -6
- data/examples/dascli +18 -16
- data/examples/faspex4.rb +15 -15
- data/examples/node.rb +12 -12
- data/examples/proxy.pac +2 -2
- data/examples/server.rb +12 -12
- data/lib/aspera/aoc.rb +344 -272
- data/lib/aspera/ascmd.rb +56 -54
- data/lib/aspera/ats_api.rb +4 -4
- data/lib/aspera/cli/basic_auth_plugin.rb +15 -12
- data/lib/aspera/cli/extended_value.rb +9 -9
- data/lib/aspera/cli/{formater.rb → formatter.rb} +69 -69
- data/lib/aspera/cli/listener/line_dump.rb +1 -1
- data/lib/aspera/cli/listener/logger.rb +1 -1
- data/lib/aspera/cli/listener/progress.rb +5 -6
- data/lib/aspera/cli/listener/progress_multi.rb +16 -21
- data/lib/aspera/cli/main.rb +72 -73
- data/lib/aspera/cli/manager.rb +112 -112
- data/lib/aspera/cli/plugin.rb +68 -48
- data/lib/aspera/cli/plugins/alee.rb +4 -4
- data/lib/aspera/cli/plugins/aoc.rb +322 -720
- data/lib/aspera/cli/plugins/ats.rb +50 -52
- data/lib/aspera/cli/plugins/bss.rb +10 -10
- data/lib/aspera/cli/plugins/config.rb +514 -410
- data/lib/aspera/cli/plugins/console.rb +12 -12
- data/lib/aspera/cli/plugins/cos.rb +18 -20
- data/lib/aspera/cli/plugins/faspex.rb +134 -136
- data/lib/aspera/cli/plugins/faspex5.rb +235 -70
- data/lib/aspera/cli/plugins/node.rb +378 -309
- data/lib/aspera/cli/plugins/orchestrator.rb +52 -49
- data/lib/aspera/cli/plugins/preview.rb +129 -120
- data/lib/aspera/cli/plugins/server.rb +137 -83
- data/lib/aspera/cli/plugins/shares.rb +77 -52
- data/lib/aspera/cli/plugins/sync.rb +13 -33
- data/lib/aspera/cli/transfer_agent.rb +61 -61
- data/lib/aspera/cli/version.rb +2 -1
- data/lib/aspera/colors.rb +3 -3
- data/lib/aspera/command_line_builder.rb +78 -74
- data/lib/aspera/cos_node.rb +31 -29
- data/lib/aspera/data_repository.rb +1 -1
- data/lib/aspera/environment.rb +30 -28
- data/lib/aspera/fasp/agent_base.rb +17 -15
- data/lib/aspera/fasp/agent_connect.rb +34 -32
- data/lib/aspera/fasp/agent_direct.rb +70 -73
- data/lib/aspera/fasp/agent_httpgw.rb +79 -74
- data/lib/aspera/fasp/agent_node.rb +26 -26
- data/lib/aspera/fasp/agent_trsdk.rb +20 -20
- data/lib/aspera/fasp/error.rb +3 -2
- data/lib/aspera/fasp/error_info.rb +11 -8
- data/lib/aspera/fasp/installation.rb +80 -80
- data/lib/aspera/fasp/listener.rb +2 -2
- data/lib/aspera/fasp/parameters.rb +103 -92
- data/lib/aspera/fasp/parameters.yaml +313 -214
- data/lib/aspera/fasp/resume_policy.rb +10 -10
- data/lib/aspera/fasp/transfer_spec.rb +22 -2
- data/lib/aspera/fasp/uri.rb +7 -7
- data/lib/aspera/faspex_gw.rb +80 -159
- data/lib/aspera/faspex_postproc.rb +77 -0
- data/lib/aspera/hash_ext.rb +3 -3
- data/lib/aspera/id_generator.rb +5 -5
- data/lib/aspera/keychain/encrypted_hash.rb +23 -28
- data/lib/aspera/keychain/macos_security.rb +21 -20
- data/lib/aspera/log.rb +13 -13
- data/lib/aspera/nagios.rb +24 -23
- data/lib/aspera/node.rb +217 -38
- data/lib/aspera/oauth.rb +78 -74
- data/lib/aspera/open_application.rb +19 -11
- data/lib/aspera/persistency_action_once.rb +4 -4
- data/lib/aspera/persistency_folder.rb +13 -13
- data/lib/aspera/preview/file_types.rb +8 -8
- data/lib/aspera/preview/generator.rb +67 -67
- data/lib/aspera/preview/utils.rb +27 -27
- data/lib/aspera/proxy_auto_config.js +63 -63
- data/lib/aspera/proxy_auto_config.rb +19 -19
- data/lib/aspera/rest.rb +65 -67
- data/lib/aspera/rest_call_error.rb +2 -1
- data/lib/aspera/rest_error_analyzer.rb +22 -21
- data/lib/aspera/rest_errors_aspera.rb +16 -16
- data/lib/aspera/secret_hider.rb +17 -14
- data/lib/aspera/ssh.rb +15 -14
- data/lib/aspera/sync.rb +177 -62
- data/lib/aspera/temp_file_manager.rb +2 -2
- data/lib/aspera/uri_reader.rb +4 -4
- data/lib/aspera/web_auth.rb +13 -64
- data/lib/aspera/web_server_simple.rb +76 -0
- data.tar.gz.sig +0 -0
- metadata +11 -6
- metadata.gz.sig +0 -0
data/lib/aspera/aoc.rb
CHANGED
@@ -8,20 +8,23 @@ require 'aspera/fasp/transfer_spec'
|
|
8
8
|
require 'base64'
|
9
9
|
require 'cgi'
|
10
10
|
|
11
|
-
Aspera::Oauth.register_token_creator(
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
11
|
+
Aspera::Oauth.register_token_creator(
|
12
|
+
:aoc_pub_link,
|
13
|
+
lambda{|o|
|
14
|
+
o.api.call({
|
15
|
+
operation: 'POST',
|
16
|
+
subpath: o.generic_parameters[:path_token],
|
17
|
+
headers: {'Accept' => 'application/json'},
|
18
|
+
json_params: o.specific_parameters[:json],
|
19
|
+
url_params: o.specific_parameters[:url].merge(scope: o.generic_parameters[:scope]) # scope is here because it changes over time (node)
|
20
|
+
})
|
21
|
+
},
|
22
|
+
lambda { |oauth|
|
23
|
+
return [oauth.specific_parameters.dig(:json, :url_token)]
|
18
24
|
})
|
19
|
-
},lambda { |oauth|
|
20
|
-
return [oauth.sparams.dig(:json,:url_token)]
|
21
|
-
})
|
22
25
|
|
23
26
|
module Aspera
|
24
|
-
class AoC < Rest
|
27
|
+
class AoC < Aspera::Rest
|
25
28
|
PRODUCT_NAME = 'Aspera on Cloud'
|
26
29
|
# Production domain of AoC
|
27
30
|
PROD_DOMAIN = 'ibmaspera.com'
|
@@ -32,7 +35,7 @@ module Aspera
|
|
32
35
|
# index offset in data repository of client app
|
33
36
|
DATA_REPO_INDEX_START = 4
|
34
37
|
# cookie prefix so that console can decode identity
|
35
|
-
|
38
|
+
COOKIE_PREFIX_CONSOLE_AOC = 'aspera.aoc'
|
36
39
|
# path in URL of public links
|
37
40
|
PUBLIC_LINK_PATHS = %w[/packages/public/receive /packages/public/send /files/public].freeze
|
38
41
|
JWT_AUDIENCE = 'https://api.asperafiles.com/api/v1/oauth2/token'
|
@@ -40,49 +43,50 @@ module Aspera
|
|
40
43
|
# minimum fields for user info if retrieval fails
|
41
44
|
USER_INFO_FIELDS_MIN = %w[name email id default_workspace_id organization_id].freeze
|
42
45
|
|
43
|
-
private_constant :MAX_REDIRECT
|
44
|
-
:
|
46
|
+
private_constant :MAX_REDIRECT,
|
47
|
+
:GLOBAL_CLIENT_APPS,
|
48
|
+
:DATA_REPO_INDEX_START,
|
49
|
+
:COOKIE_PREFIX_CONSOLE_AOC,
|
50
|
+
:PUBLIC_LINK_PATHS,
|
51
|
+
:JWT_AUDIENCE,
|
52
|
+
:OAUTH_API_SUBPATH,
|
53
|
+
:USER_INFO_FIELDS_MIN
|
45
54
|
|
46
55
|
# various API scopes supported
|
47
56
|
SCOPE_FILES_SELF = 'self'
|
48
57
|
SCOPE_FILES_USER = 'user:all'
|
49
58
|
SCOPE_FILES_ADMIN = 'admin:all'
|
50
59
|
SCOPE_FILES_ADMIN_USER = 'admin-user:all'
|
51
|
-
SCOPE_FILES_ADMIN_USER_USER = SCOPE_FILES_ADMIN_USER
|
60
|
+
SCOPE_FILES_ADMIN_USER_USER = "#{SCOPE_FILES_ADMIN_USER}+#{SCOPE_FILES_USER}"
|
52
61
|
SCOPE_NODE_USER = 'user:all'
|
53
62
|
SCOPE_NODE_ADMIN = 'admin:all'
|
54
|
-
PATH_SEPARATOR = '/'
|
55
63
|
FILES_APP = 'files'
|
56
64
|
PACKAGES_APP = 'packages'
|
57
65
|
API_V1 = 'api/v1'
|
58
66
|
# error message when entity not found
|
59
67
|
ENTITY_NOT_FOUND = 'No such'
|
60
68
|
|
61
|
-
# class instance variable, access with accessors on class
|
62
|
-
@use_standard_ports = true
|
63
|
-
|
64
69
|
# class static methods
|
65
70
|
class << self
|
66
|
-
attr_accessor :use_standard_ports
|
67
71
|
# strings /Applications/Aspera\ Drive.app/Contents/MacOS/AsperaDrive|grep -E '.{100}==$'|base64 --decode
|
68
72
|
def get_client_info(client_name=GLOBAL_CLIENT_APPS.first)
|
69
73
|
client_index = GLOBAL_CLIENT_APPS.index(client_name)
|
70
74
|
raise "no such pre-defined client: #{client_name}" if client_index.nil?
|
71
|
-
return client_name,Base64.urlsafe_encode64(DataRepository.instance.data(DATA_REPO_INDEX_START + client_index))
|
75
|
+
return client_name, Base64.urlsafe_encode64(DataRepository.instance.data(DATA_REPO_INDEX_START + client_index))
|
72
76
|
end
|
73
77
|
|
74
78
|
# @param url of AoC instance
|
75
79
|
# @return organization id in url and AoC domain: ibmaspera.com, asperafiles.com or qa.asperafiles.com, etc...
|
76
80
|
def parse_url(aoc_org_url)
|
77
|
-
uri = URI.parse(aoc_org_url.gsub(
|
81
|
+
uri = URI.parse(aoc_org_url.gsub(%r{/+$}, ''))
|
78
82
|
instance_fqdn = uri.host
|
79
|
-
Log.log.debug
|
83
|
+
Log.log.debug{"instance_fqdn=#{instance_fqdn}"}
|
80
84
|
raise "No host found in URL.Please check URL format: https://myorg.#{PROD_DOMAIN}" if instance_fqdn.nil?
|
81
|
-
organization,instance_domain = instance_fqdn.split('.',2)
|
82
|
-
Log.log.debug
|
83
|
-
Log.log.debug
|
85
|
+
organization, instance_domain = instance_fqdn.split('.', 2)
|
86
|
+
Log.log.debug{"instance_domain=#{instance_domain}"}
|
87
|
+
Log.log.debug{"organization=#{organization}"}
|
84
88
|
raise "expecting a public FQDN for #{PRODUCT_NAME}" if instance_domain.nil?
|
85
|
-
return organization,instance_domain
|
89
|
+
return organization, instance_domain
|
86
90
|
end
|
87
91
|
|
88
92
|
# base API url depends on domain, which could be "qa.xxx"
|
@@ -90,22 +94,22 @@ module Aspera
|
|
90
94
|
return "https://#{organization}.#{api_domain}"
|
91
95
|
end
|
92
96
|
|
93
|
-
def metering_api(entitlement_id,customer_id,api_domain=PROD_DOMAIN)
|
97
|
+
def metering_api(entitlement_id, customer_id, api_domain=PROD_DOMAIN)
|
94
98
|
return Rest.new({
|
95
99
|
base_url: "#{api_base_url(api_domain: api_domain)}/metering/v1",
|
96
|
-
headers: {'X-Aspera-Entitlement-Authorization' => Rest.basic_creds(entitlement_id,customer_id)}
|
100
|
+
headers: {'X-Aspera-Entitlement-Authorization' => Rest.basic_creds(entitlement_id, customer_id)}
|
97
101
|
})
|
98
102
|
end
|
99
103
|
|
100
104
|
# node API scopes
|
101
|
-
def node_scope(access_key,scope)
|
105
|
+
def node_scope(access_key, scope)
|
102
106
|
return "node.#{access_key}:#{scope}"
|
103
107
|
end
|
104
108
|
|
105
109
|
# check option "link"
|
106
110
|
# if present try to get token value (resolve redirection if short links used)
|
107
111
|
# then set options url/token/auth
|
108
|
-
def resolve_pub_link(a_auth,a_opt)
|
112
|
+
def resolve_pub_link(a_auth, a_opt)
|
109
113
|
public_link_url = a_opt[:link]
|
110
114
|
return if public_link_url.nil?
|
111
115
|
raise 'do not use both link and url options' unless a_opt[:url].nil?
|
@@ -115,10 +119,10 @@ module Aspera
|
|
115
119
|
# detect if it's an expected format
|
116
120
|
if PUBLIC_LINK_PATHS.include?(uri.path)
|
117
121
|
url_param_token_pair = URI.decode_www_form(uri.query).find{|e|e.first.eql?('token')}
|
118
|
-
raise ArgumentError,'link option must be URL with "token" parameter' if url_param_token_pair.nil?
|
122
|
+
raise ArgumentError, 'link option must be URL with "token" parameter' if url_param_token_pair.nil?
|
119
123
|
# ok we get it !
|
120
124
|
a_opt[:url] = 'https://' + uri.host
|
121
|
-
a_auth[:
|
125
|
+
a_auth[:grant_method] = :aoc_pub_link
|
122
126
|
a_auth[:aoc_pub_link] = {
|
123
127
|
url: {grant_type: 'url_token'}, # URL args
|
124
128
|
json: {url_token: url_param_token_pair.last} # JSON body
|
@@ -127,74 +131,45 @@ module Aspera
|
|
127
131
|
a_auth[:aoc_pub_link][:json][:password] = a_opt[:password] unless a_opt[:password].nil?
|
128
132
|
return # SUCCESS
|
129
133
|
end
|
130
|
-
Log.log.debug
|
134
|
+
Log.log.debug{"no expected format: #{public_link_url}"}
|
131
135
|
r = Net::HTTP.get_response(uri)
|
132
136
|
# not a redirection
|
133
|
-
raise ArgumentError,'link option must be redirect or have token parameter' unless r.code.start_with?('3')
|
137
|
+
raise ArgumentError, 'link option must be redirect or have token parameter' unless r.code.start_with?('3')
|
134
138
|
public_link_url = r['location']
|
135
139
|
raise 'no location in redirection' if public_link_url.nil?
|
136
|
-
Log.log.debug
|
140
|
+
Log.log.debug{"redirect to: #{public_link_url}"}
|
137
141
|
end # loop
|
138
142
|
raise "exceeded max redirection: #{MAX_REDIRECT}"
|
139
143
|
end
|
140
|
-
|
141
|
-
# additional transfer spec (tags) for package information
|
142
|
-
def package_tags(package_info,operation)
|
143
|
-
return {'tags' => {'aspera' => {'files' => {
|
144
|
-
'package_id' => package_info['id'],
|
145
|
-
'package_name' => package_info['name'],
|
146
|
-
'package_operation' => operation
|
147
|
-
}}}}
|
148
|
-
end
|
149
|
-
|
150
|
-
# add details to show in analytics
|
151
|
-
def analytics_ts(app,direction,ws_id,ws_name)
|
152
|
-
# translate transfer to operation
|
153
|
-
operation =
|
154
|
-
case direction
|
155
|
-
when Fasp::TransferSpec::DIRECTION_SEND then 'upload'
|
156
|
-
when Fasp::TransferSpec::DIRECTION_RECEIVE then 'download'
|
157
|
-
else raise "ERROR: unexpected value: #{direction}"
|
158
|
-
end
|
159
|
-
|
160
|
-
return {
|
161
|
-
'tags' => {
|
162
|
-
'aspera' => {
|
163
|
-
'usage_id' => "aspera.files.workspace.#{ws_id}", # activity tracking
|
164
|
-
'files' => {
|
165
|
-
'files_transfer_action' => "#{operation}_#{app.gsub(/s$/,'')}",
|
166
|
-
'workspace_name' => ws_name, # activity tracking
|
167
|
-
'workspace_id' => ws_id
|
168
|
-
}
|
169
|
-
}
|
170
|
-
}
|
171
|
-
}
|
172
|
-
end
|
173
144
|
end # static methods
|
174
145
|
|
175
|
-
#
|
146
|
+
# CLI options that are also options to initialize
|
147
|
+
OPTIONS_NEW = %i[link url auth client_id client_secret scope redirect_uri private_key passphrase username password].freeze
|
148
|
+
|
149
|
+
# @param any of OPTIONS_NEW + subpath
|
176
150
|
def initialize(opt)
|
177
|
-
raise ArgumentError,'Missing mandatory option: scope' if opt[:scope].nil?
|
151
|
+
raise ArgumentError, 'Missing mandatory option: scope' if opt[:scope].nil?
|
178
152
|
|
179
153
|
# access key secrets are provided out of band to get node api access
|
180
154
|
# key: access key
|
181
155
|
# value: associated secret
|
182
|
-
@
|
183
|
-
@
|
156
|
+
@secret_finder = nil
|
157
|
+
@cache_user_info = nil
|
158
|
+
@cache_url_token_info = nil
|
184
159
|
|
185
160
|
# init rest params
|
186
161
|
aoc_rest_p = {auth: {type: :oauth2}}
|
187
162
|
# shortcut to auth section
|
188
163
|
aoc_auth_p = aoc_rest_p[:auth]
|
189
164
|
|
190
|
-
# sets opt[:url], aoc_rest_p[:auth][:
|
191
|
-
self.class.resolve_pub_link(aoc_auth_p,opt)
|
165
|
+
# sets opt[:url], aoc_rest_p[:auth][:grant_method], [:auth][:aoc_pub_link] if there is a link
|
166
|
+
self.class.resolve_pub_link(aoc_auth_p, opt)
|
192
167
|
|
193
168
|
# test here because link may set url
|
194
|
-
raise ArgumentError,'Missing mandatory option: url' if opt[:url].nil?
|
169
|
+
raise ArgumentError, 'Missing mandatory option: url' if opt[:url].nil?
|
195
170
|
|
196
171
|
# get org name and domain from url
|
197
|
-
organization,instance_domain = self.class.parse_url(opt[:url])
|
172
|
+
organization, instance_domain = self.class.parse_url(opt[:url])
|
198
173
|
# this is the base API url
|
199
174
|
api_url_base = self.class.api_base_url(api_domain: instance_domain)
|
200
175
|
# API URL, including subpath (version ...)
|
@@ -206,25 +181,25 @@ module Aspera
|
|
206
181
|
aoc_auth_p[:scope] = opt[:scope]
|
207
182
|
|
208
183
|
# filled if pub link
|
209
|
-
if !aoc_auth_p.
|
210
|
-
raise ArgumentError,'Missing mandatory option: auth' if opt[:auth].nil?
|
211
|
-
aoc_auth_p[:
|
184
|
+
if !aoc_auth_p.key?(:grant_method)
|
185
|
+
raise ArgumentError, 'Missing mandatory option: auth' if opt[:auth].nil?
|
186
|
+
aoc_auth_p[:grant_method] = opt[:auth]
|
212
187
|
end
|
213
188
|
|
214
189
|
if aoc_auth_p[:client_id].nil?
|
215
|
-
aoc_auth_p[:client_id],aoc_auth_p[:client_secret] = self.class.get_client_info
|
190
|
+
aoc_auth_p[:client_id], aoc_auth_p[:client_secret] = self.class.get_client_info
|
216
191
|
end
|
217
192
|
|
218
193
|
# fill other auth parameters based on Oauth method
|
219
|
-
case aoc_auth_p[:
|
194
|
+
case aoc_auth_p[:grant_method]
|
220
195
|
when :web
|
221
|
-
raise ArgumentError,'Missing mandatory option: redirect_uri' if opt[:redirect_uri].nil?
|
196
|
+
raise ArgumentError, 'Missing mandatory option: redirect_uri' if opt[:redirect_uri].nil?
|
222
197
|
aoc_auth_p[:web] = {redirect_uri: opt[:redirect_uri]}
|
223
198
|
when :jwt
|
224
|
-
raise ArgumentError,'Missing mandatory option: private_key' if opt[:private_key].nil?
|
225
|
-
raise ArgumentError,'Missing mandatory option: username' if opt[:username].nil?
|
199
|
+
raise ArgumentError, 'Missing mandatory option: private_key' if opt[:private_key].nil?
|
200
|
+
raise ArgumentError, 'Missing mandatory option: username' if opt[:username].nil?
|
226
201
|
aoc_auth_p[:jwt] = {
|
227
|
-
private_key_obj: OpenSSL::PKey::RSA.new(opt[:private_key],opt[:passphrase]),
|
202
|
+
private_key_obj: OpenSSL::PKey::RSA.new(opt[:private_key], opt[:passphrase]),
|
228
203
|
payload: {
|
229
204
|
iss: aoc_auth_p[:client_id], # issuer
|
230
205
|
sub: opt[:username], # subject
|
@@ -235,240 +210,337 @@ module Aspera
|
|
235
210
|
aoc_auth_p[:jwt][:payload][:org] = organization if GLOBAL_CLIENT_APPS.include?(aoc_auth_p[:client_id])
|
236
211
|
when :aoc_pub_link
|
237
212
|
# basic auth required for /token
|
238
|
-
aoc_auth_p[:auth] = {type: :basic, username: aoc_auth_p[:client_id],password: aoc_auth_p[:client_secret]}
|
239
|
-
else raise "ERROR: unsupported auth method: #{aoc_auth_p[:
|
213
|
+
aoc_auth_p[:auth] = {type: :basic, username: aoc_auth_p[:client_id], password: aoc_auth_p[:client_secret]}
|
214
|
+
else raise "ERROR: unsupported auth method: #{aoc_auth_p[:grant_method]}"
|
240
215
|
end
|
241
216
|
super(aoc_rest_p)
|
242
217
|
end
|
243
218
|
|
244
219
|
def url_token_data
|
245
|
-
return nil unless params[:auth][:
|
220
|
+
return nil unless params[:auth][:grant_method].eql?(:aoc_pub_link)
|
221
|
+
return @cache_url_token_info unless @cache_url_token_info.nil?
|
246
222
|
# TODO: can there be several in list ?
|
247
|
-
|
223
|
+
@cache_url_token_info = read('url_tokens')[:data].first
|
224
|
+
return @cache_url_token_info
|
225
|
+
end
|
226
|
+
|
227
|
+
def additional_persistence_ids
|
228
|
+
return [current_user_info['id']] if url_token_data.nil?
|
229
|
+
return [] # TODO : url_token_data['id'] ?
|
248
230
|
end
|
249
231
|
|
250
|
-
def
|
251
|
-
raise '
|
252
|
-
raise '
|
253
|
-
@
|
232
|
+
def secret_finder=(secret_finder)
|
233
|
+
raise 'secret finder already set' unless @secret_finder.nil?
|
234
|
+
raise 'secret finder must have lookup_secret' unless secret_finder.respond_to?(:lookup_secret)
|
235
|
+
@secret_finder = secret_finder
|
254
236
|
end
|
255
237
|
|
256
238
|
# cached user information
|
257
|
-
def
|
258
|
-
if @
|
239
|
+
def current_user_info(exception: false)
|
240
|
+
if @cache_user_info.nil?
|
259
241
|
# get our user's default information
|
260
|
-
@
|
242
|
+
@cache_user_info =
|
261
243
|
begin
|
262
244
|
read('self')[:data]
|
263
245
|
rescue StandardError => e
|
264
|
-
|
246
|
+
raise e if exception
|
247
|
+
Log.log.debug{"ignoring error: #{e}"}
|
265
248
|
{}
|
266
249
|
end
|
267
|
-
USER_INFO_FIELDS_MIN.each{|f|@
|
250
|
+
USER_INFO_FIELDS_MIN.each{|f|@cache_user_info[f] = 'unknown' if @cache_user_info[f].nil?}
|
268
251
|
end
|
269
|
-
return @
|
252
|
+
return @cache_user_info
|
270
253
|
end
|
271
254
|
|
272
|
-
#
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
280
|
-
|
281
|
-
# - transfer spec for aspera on cloud, based on node information and file id
|
282
|
-
# - source and token regeneration method
|
283
|
-
def tr_spec(app,direction,node_file,ts_add)
|
284
|
-
# get node api
|
285
|
-
node_api = get_node_api(node_file[:node_info])
|
286
|
-
# this lambda returns the bearer token for node, if
|
287
|
-
token_generation_lambda = lambda{|do_refresh|node_api.oauth_token(force_refresh: do_refresh)}
|
288
|
-
# prepare transfer specification
|
289
|
-
# note xfer_id and xfer_retry are set by the transfer agent itself
|
290
|
-
transfer_spec = {
|
291
|
-
'direction' => direction,
|
292
|
-
'token' => token_generation_lambda.call(false), # first time, use cache
|
293
|
-
'tags' => {
|
294
|
-
'aspera' => {
|
295
|
-
'app' => app,
|
296
|
-
'files' => {
|
297
|
-
'node_id' => node_file[:node_info]['id']
|
298
|
-
}, # files
|
299
|
-
'node' => {
|
300
|
-
'access_key' => node_file[:node_info]['access_key'],
|
301
|
-
#'file_id' => ts_add['source_root_id']
|
302
|
-
'file_id' => node_file[:file_id]
|
303
|
-
} # node
|
304
|
-
} # aspera
|
305
|
-
} # tags
|
306
|
-
}
|
307
|
-
# add remote host info
|
308
|
-
if self.class.use_standard_ports
|
309
|
-
# get default TCP/UDP ports and transfer user
|
310
|
-
transfer_spec.merge!(Fasp::TransferSpec::AK_TSPEC_BASE)
|
311
|
-
# by default: same address as node API
|
312
|
-
transfer_spec['remote_host'] = node_file[:node_info]['host']
|
313
|
-
# 30 it's necessarily https scheme: webui does not allow anything else
|
314
|
-
if node_file[:node_info]['transfer_url'].is_a?(String) && !node_file[:node_info]['transfer_url'].empty?
|
315
|
-
transfer_spec['remote_host'] = URI.parse(node_file[:node_info]['transfer_url']).host
|
255
|
+
# @returns [Aspera::Node] a node API for access key
|
256
|
+
# @param node_id [String] identifier of node in AoC
|
257
|
+
# @param scope e.g. SCOPE_NODE_USER, or nil (requires secret)
|
258
|
+
def node_api_from(node_id: nil, workspace_info: nil, package_info: nil, scope: nil)
|
259
|
+
if node_id.nil?
|
260
|
+
if package_info.nil?
|
261
|
+
raise 'INTERNAL ERROR: either node_id or package_info is required'
|
262
|
+
else
|
263
|
+
node_id = package_info['node_id']
|
316
264
|
end
|
317
|
-
else
|
318
|
-
# retrieve values from API
|
319
|
-
std_t_spec = node_api.create(
|
320
|
-
'files/download_setup',
|
321
|
-
{transfer_requests: [{ transfer_request: {paths: [{'source' => '/'}] } }] }
|
322
|
-
)[:data]['transfer_specs'].first['transfer_spec']
|
323
|
-
%w[remote_host remote_user ssh_port fasp_port].each {|i| transfer_spec[i] = std_t_spec[i]}
|
324
265
|
end
|
325
|
-
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
|
332
|
-
|
333
|
-
end
|
334
|
-
|
335
|
-
# returns a node API for access key
|
336
|
-
# @param node_info [Hash] with 'url' and 'access_key'
|
337
|
-
# @param scope e.g. SCOPE_NODE_USER
|
338
|
-
# no scope: requires secret
|
339
|
-
# if secret provided beforehand: use it
|
340
|
-
def get_node_api(node_info, scope: SCOPE_NODE_USER, use_secret: true)
|
341
|
-
raise 'internal error' unless node_info.is_a?(Hash) && node_info.has_key?('url') && node_info.has_key?('access_key')
|
342
|
-
# get optional secret unless :use_secret is false
|
343
|
-
ak_secret = @key_chain.get_secret(url: node_info['url'], username: node_info['access_key'], mandatory: false) if use_secret && !@key_chain.nil?
|
344
|
-
raise "There must be at least one of: 'secret' or 'scope' for access key #{node_info['access_key']}" if ak_secret.nil? && scope.nil?
|
266
|
+
if workspace_info.nil?
|
267
|
+
if package_info.nil?
|
268
|
+
raise 'INTERNAL ERROR: either workspace_info or package_info is required'
|
269
|
+
else
|
270
|
+
workspace_info = package_info['workspace_id']
|
271
|
+
end
|
272
|
+
end
|
273
|
+
node_info = read("nodes/#{node_id}")[:data]
|
345
274
|
node_rest_params = {base_url: node_info['url']}
|
346
275
|
# if secret is available
|
347
|
-
if
|
276
|
+
if scope.nil?
|
348
277
|
node_rest_params[:auth] = {
|
349
278
|
type: :basic,
|
350
279
|
username: node_info['access_key'],
|
351
|
-
password:
|
280
|
+
password: @secret_finder&.lookup_secret(url: node_info['url'], username: node_info['access_key'], mandatory: true)
|
352
281
|
}
|
353
282
|
else
|
354
|
-
#
|
355
|
-
node_rest_params[:headers] = {'X-Aspera-AccessKey' => node_info['access_key']}
|
283
|
+
# OAuth bearer token
|
356
284
|
node_rest_params[:auth] = params[:auth].clone
|
357
|
-
node_rest_params[:auth][:scope] = self.class.node_scope(node_info['access_key'],scope)
|
285
|
+
node_rest_params[:auth][:scope] = self.class.node_scope(node_info['access_key'], scope)
|
286
|
+
# special header required for bearer token only
|
287
|
+
node_rest_params[:headers] = {Aspera::Node::HEADER_X_ASPERA_ACCESS_KEY => node_info['access_key']}
|
358
288
|
end
|
359
|
-
|
289
|
+
app_info = {
|
290
|
+
node_info: node_info,
|
291
|
+
workspace_info: workspace_info,
|
292
|
+
app: package_info.nil? ? FILES_APP : PACKAGES_APP,
|
293
|
+
api: self # for callback
|
294
|
+
}
|
295
|
+
app_info[:package_info] = package_info unless package_info.nil?
|
296
|
+
return Node.new(params: node_rest_params, app_info: app_info)
|
360
297
|
end
|
361
298
|
|
362
|
-
#
|
363
|
-
# @
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
299
|
+
# Query entity type by name and returns the id if a single entry only
|
300
|
+
# @param entity_type path of entity in API
|
301
|
+
# @param entity_name name of searched entity
|
302
|
+
# @param options additional search options
|
303
|
+
def lookup_entity_by_name(entity_type, entity_name, options={})
|
304
|
+
# returns entities whose name contains value (case insensitive)
|
305
|
+
matching_items = read(entity_type, options.merge({'q' => CGI.escape(entity_name)}))[:data]
|
306
|
+
case matching_items.length
|
307
|
+
when 1 then return matching_items.first
|
308
|
+
when 0 then raise %Q{#{ENTITY_NOT_FOUND} #{entity_type}: "#{entity_name}"}
|
309
|
+
else
|
310
|
+
# multiple case insensitive partial matches, try case insensitive full match
|
311
|
+
# (anyway AoC does not allow creation of 2 entities with same case insensitive name)
|
312
|
+
name_matches = matching_items.select{|i|i['name'].casecmp?(entity_name)}
|
313
|
+
case name_matches.length
|
314
|
+
when 1 then return name_matches.first
|
315
|
+
when 0 then raise %Q(#{entity_type}: multiple case insensitive partial match for: "#{entity_name}": #{matching_items.map{|i|i['name']}} but no case insensitive full match. Please be more specific or give exact name.) # rubocop:disable Layout/LineLength
|
316
|
+
else raise "Two entities cannot have the same case insensitive name: #{name_matches.map{|i|i['name']}}"
|
317
|
+
end
|
318
|
+
end
|
373
319
|
end
|
374
320
|
|
375
|
-
#
|
376
|
-
def
|
377
|
-
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
385
|
-
|
386
|
-
|
387
|
-
|
321
|
+
# Check metadata: remove when validation is done server side
|
322
|
+
def validate_metadata(pkg_data)
|
323
|
+
# validate only for shared inboxes
|
324
|
+
return unless pkg_data['recipients'].is_a?(Array) &&
|
325
|
+
pkg_data['recipients'].first.is_a?(Hash) &&
|
326
|
+
pkg_data['recipients'].first.key?('type') &&
|
327
|
+
pkg_data['recipients'].first['type'].eql?('dropbox')
|
328
|
+
meta_schema = read("dropboxes/#{pkg_data['recipients'].first['id']}")[:data]['metadata_schema']
|
329
|
+
if meta_schema.nil? || meta_schema.empty?
|
330
|
+
Log.log.debug('no metadata in shared inbox')
|
331
|
+
return
|
332
|
+
end
|
333
|
+
pkg_meta = pkg_data['metadata']
|
334
|
+
raise "package requires metadata: #{meta_schema}" unless pkg_data.key?('metadata')
|
335
|
+
raise 'metadata must be an Array' unless pkg_meta.is_a?(Array)
|
336
|
+
Log.dump(:metadata, pkg_meta)
|
337
|
+
pkg_meta.each do |field|
|
338
|
+
raise 'metadata field must be Hash' unless field.is_a?(Hash)
|
339
|
+
raise 'metadata field must have name' unless field.key?('name')
|
340
|
+
raise 'metadata field must have values' unless field.key?('values')
|
341
|
+
raise 'metadata values must be an Array' unless field['values'].is_a?(Array)
|
342
|
+
raise "unknown metadata field: #{field['name']}" if meta_schema.select{|i|i['name'].eql?(field['name'])}.empty?
|
343
|
+
end
|
344
|
+
meta_schema.each do |field|
|
345
|
+
provided = pkg_meta.select{|i|i['name'].eql?(field['name'])}
|
346
|
+
raise "only one field with name #{field['name']} allowed" if provided.count > 1
|
347
|
+
raise "missing mandatory field: #{field['name']}" if field['required'] && provided.empty?
|
388
348
|
end
|
389
|
-
# process all folders
|
390
|
-
return true
|
391
349
|
end
|
392
350
|
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
return
|
351
|
+
# Normalize package creation recipient lists as expected by AoC API
|
352
|
+
# AoC expects {type: , id: }, but ascli allows providing either the native values or just a name
|
353
|
+
# in that case, the name is resolved and replaced with {type: , id: }
|
354
|
+
# @param package_data The whole package creation payload
|
355
|
+
# @param recipient_list_field The field in structure, i.e. recipients or bcc_recipients
|
356
|
+
# @return nil package_data is modified
|
357
|
+
def resolve_package_recipients(package_data, ws_id, recipient_list_field, new_user_option)
|
358
|
+
return unless package_data.key?(recipient_list_field)
|
359
|
+
raise "#{recipient_list_field} must be an Array" unless package_data[recipient_list_field].is_a?(Array)
|
360
|
+
new_user_option = {'package_contact' => true} if new_user_option.nil?
|
361
|
+
raise 'new_user_option must be a Hash' unless new_user_option.is_a?(Hash)
|
362
|
+
# list with resolved elements
|
363
|
+
resolved_list = []
|
364
|
+
package_data[recipient_list_field].each do |short_recipient_info|
|
365
|
+
case short_recipient_info
|
366
|
+
when Hash # native API information, check keys
|
367
|
+
raise "#{recipient_list_field} element shall have fields: id and type" unless short_recipient_info.keys.sort.eql?(%w[id type])
|
368
|
+
when String # CLI helper: need to resolve provided name to type/id
|
369
|
+
# email: user, else dropbox
|
370
|
+
entity_type = short_recipient_info.include?('@') ? 'contacts' : 'dropboxes'
|
371
|
+
begin
|
372
|
+
full_recipient_info = lookup_entity_by_name(entity_type, short_recipient_info, {'current_workspace_id' => ws_id})
|
373
|
+
rescue RuntimeError => e
|
374
|
+
raise e unless e.message.start_with?(ENTITY_NOT_FOUND)
|
375
|
+
# dropboxes cannot be created on the fly
|
376
|
+
raise "No such shared inbox in workspace #{ws_id}" if entity_type.eql?('dropboxes')
|
377
|
+
# unknown user: create it as external user
|
378
|
+
full_recipient_info = create('contacts', {
|
379
|
+
'current_workspace_id' => ws_id,
|
380
|
+
'email' => short_recipient_info}.merge(new_user_option))[:data]
|
381
|
+
end
|
382
|
+
short_recipient_info = if entity_type.eql?('dropboxes')
|
383
|
+
{'id' => full_recipient_info['id'], 'type' => 'dropbox'}
|
384
|
+
else
|
385
|
+
{'id' => full_recipient_info['source_id'], 'type' => full_recipient_info['source_type']}
|
386
|
+
end
|
387
|
+
else # unexpected extended value, must be String or Hash
|
388
|
+
raise "#{recipient_list_field} item must be a String (email, shared inbox) or Hash (id,type)"
|
389
|
+
end # type of recipient info
|
390
|
+
# add original or resolved recipient info
|
391
|
+
resolved_list.push(short_recipient_info)
|
392
|
+
end
|
393
|
+
# replace with resolved elements
|
394
|
+
package_data[recipient_list_field] = resolved_list
|
395
|
+
return nil
|
401
396
|
end
|
402
397
|
|
403
|
-
|
404
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
411
|
-
|
412
|
-
|
413
|
-
|
414
|
-
|
415
|
-
if @resolve_state[:path].empty?
|
416
|
-
@resolve_state[:result][:file_id] = entry['target_id']
|
417
|
-
else
|
418
|
-
get_node_api(@resolve_state[:result][:node_info]).crawl(self,{method: :process_resolve_node_file, top_file_id: entry['target_id']})
|
419
|
-
end
|
420
|
-
when 'folder'
|
421
|
-
if @resolve_state[:path].empty?
|
422
|
-
# found: store
|
423
|
-
@resolve_state[:result][:file_id] = entry['id']
|
424
|
-
return false
|
398
|
+
# CLI allows simplified format for metadata: transform if necessary for API
|
399
|
+
def update_package_metadata_for_api(pkg_data)
|
400
|
+
case pkg_data['metadata']
|
401
|
+
when Array, NilClass # no action
|
402
|
+
when Hash
|
403
|
+
api_meta = []
|
404
|
+
pkg_data['metadata'].each do |k, v|
|
405
|
+
api_meta.push({
|
406
|
+
# 'input_type' => 'single-dropdown',
|
407
|
+
'name' => k,
|
408
|
+
'values' => v.is_a?(Array) ? v : [v]
|
409
|
+
})
|
425
410
|
end
|
426
|
-
|
427
|
-
|
411
|
+
pkg_data['metadata'] = api_meta
|
412
|
+
else raise "metadata field if not of expected type: #{pkg_meta.class}"
|
428
413
|
end
|
429
|
-
|
430
|
-
return true
|
414
|
+
return nil
|
431
415
|
end
|
432
416
|
|
433
|
-
#
|
434
|
-
# @param
|
435
|
-
# @param
|
436
|
-
#
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
|
417
|
+
# create a package
|
418
|
+
# @param package_data [Hash] package creation (with extensions...)
|
419
|
+
# @param validate_meta [TrueClass,FalseClass] true to validate parameters locally
|
420
|
+
# @param new_user_option [Hash] options if an unknown user is specified
|
421
|
+
# @return transfer spec, node api and package information
|
422
|
+
def create_package_simple(package_data, validate_meta, new_user_option)
|
423
|
+
update_package_metadata_for_api(package_data)
|
424
|
+
# list of files to include in package, optional
|
425
|
+
# package_data['file_names']||=[..list of filenames to transfer...]
|
426
|
+
|
427
|
+
# lookup users
|
428
|
+
resolve_package_recipients(package_data, package_data['workspace_id'], 'recipients', new_user_option)
|
429
|
+
resolve_package_recipients(package_data, package_data['workspace_id'], 'bcc_recipients', new_user_option)
|
430
|
+
|
431
|
+
validate_metadata(package_data) if validate_meta
|
432
|
+
|
433
|
+
# create a new package container
|
434
|
+
created_package = create('packages', package_data)[:data]
|
435
|
+
|
436
|
+
package_node_api = node_api_from(package_info: created_package, scope: AoC::SCOPE_NODE_USER)
|
437
|
+
|
438
|
+
# tell AoC what to expect in package: 1 transfer (can also be done after transfer)
|
439
|
+
# TODO: if multi session was used we should probably tell
|
440
|
+
# also, currently no "multi-source" , i.e. only from client-side files, unless "node" agent is used
|
441
|
+
update("packages/#{created_package['id']}", {'sent' => true, 'transfers_expected' => 1})[:data]
|
442
|
+
|
443
|
+
return {
|
444
|
+
spec: package_node_api.transfer_spec_gen4(created_package['contents_file_id'], Fasp::TransferSpec::DIRECTION_SEND),
|
445
|
+
node: package_node_api,
|
446
|
+
info: created_package
|
447
|
+
}
|
448
|
+
end
|
449
|
+
|
450
|
+
# Add transferspec
|
451
|
+
# callback in Aspera::Node (transfer_spec_gen4)
|
452
|
+
def add_ts_tags(transfer_spec:, app_info:)
|
453
|
+
# translate transfer direction to upload/download
|
454
|
+
transfer_type = Fasp::TransferSpec.action(transfer_spec)
|
455
|
+
# Analytics tags
|
456
|
+
################
|
457
|
+
ws_info = app_info[:workspace_info]
|
458
|
+
transfer_spec.deep_merge!({
|
459
|
+
'tags' => {
|
460
|
+
'aspera' => {
|
461
|
+
'usage_id' => "aspera.files.workspace.#{ws_info['id']}", # activity tracking
|
462
|
+
'files' => {
|
463
|
+
'files_transfer_action' => "#{transfer_type}_#{app_info[:app].gsub(/s$/, '')}",
|
464
|
+
'workspace_name' => ws_info['name'], # activity tracking
|
465
|
+
'workspace_id' => ws_info['id']
|
466
|
+
}
|
467
|
+
}
|
468
|
+
}
|
469
|
+
})
|
470
|
+
# Console cookie
|
471
|
+
################
|
472
|
+
# we are sure that fields are not nil
|
473
|
+
cookie_elements = [app_info[:app], current_user_info['name'], current_user_info['email']].map{|e|Base64.strict_encode64(e)}
|
474
|
+
cookie_elements.unshift(COOKIE_PREFIX_CONSOLE_AOC)
|
475
|
+
transfer_spec['cookie'] = cookie_elements.join(':')
|
476
|
+
# Application tags
|
477
|
+
##################
|
478
|
+
case app_info[:app]
|
479
|
+
when FILES_APP
|
480
|
+
file_id = transfer_spec['tags']['aspera']['node']['file_id']
|
481
|
+
transfer_spec.deep_merge!({'tags' => {'aspera' => {'files' => {'parentCwd' => "#{app_info[:node_info]['id']}:#{file_id}"}}}}) \
|
482
|
+
unless transfer_spec.key?('remote_access_key')
|
483
|
+
when PACKAGES_APP
|
484
|
+
transfer_spec.deep_merge!({
|
485
|
+
'tags' => {
|
486
|
+
'aspera' => {
|
487
|
+
'files' => {
|
488
|
+
'package_id' => app_info[:package_info]['id'],
|
489
|
+
'package_name' => app_info[:package_info]['name'],
|
490
|
+
'package_operation' => transfer_type
|
491
|
+
}}}})
|
449
492
|
end
|
450
|
-
|
493
|
+
transfer_spec['tags']['aspera']['files']['node_id'] = app_info[:node_info]['id']
|
494
|
+
transfer_spec['tags']['aspera']['app'] = app_info[:app]
|
451
495
|
end
|
452
496
|
|
453
|
-
|
454
|
-
#
|
455
|
-
|
456
|
-
|
457
|
-
#
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
468
|
-
|
469
|
-
|
470
|
-
|
497
|
+
ID_AK_ADMIN = 'ASPERA_ACCESS_KEY_ADMIN'
|
498
|
+
# Callback from Plugins::Node
|
499
|
+
def permissions_create_params(create_param:, app_info:)
|
500
|
+
# workspace shared folder:
|
501
|
+
# access_id = "#{ID_AK_ADMIN}_WS_#{ app_info[:workspace_info]['id']}"
|
502
|
+
default_params = {
|
503
|
+
# 'access_type' => 'user', # mandatory: user or group
|
504
|
+
# 'access_id' => access_id, # id of user or group
|
505
|
+
'tags' => {
|
506
|
+
'aspera' => {
|
507
|
+
'files' => {
|
508
|
+
'workspace' => {
|
509
|
+
'id' => app_info[:workspace_info]['id'],
|
510
|
+
'workspace_name' => app_info[:workspace_info]['name'],
|
511
|
+
'user_name' => current_user_info['name'],
|
512
|
+
'shared_by_user_id' => current_user_info['id'],
|
513
|
+
'shared_by_name' => current_user_info['name'],
|
514
|
+
'shared_by_email' => current_user_info['email'],
|
515
|
+
# 'shared_with_name' => access_id,
|
516
|
+
'access_key' => app_info[:node_info]['access_key'],
|
517
|
+
'node' => app_info[:node_info]['name']}}}}}
|
518
|
+
create_param.deep_merge!(default_params)
|
519
|
+
if create_param.key?('with')
|
520
|
+
contact_info = lookup_entity_by_name(
|
521
|
+
'contacts',
|
522
|
+
create_param['with'],
|
523
|
+
{'current_workspace_id' => app_info[:workspace_info]['id'], 'context' => 'share_folder'})
|
524
|
+
create_param.delete('with')
|
525
|
+
create_param['access_type'] = contact_info['source_type']
|
526
|
+
create_param['access_id'] = contact_info['source_id']
|
527
|
+
create_param['tags']['aspera']['files']['workspace']['shared_with_name'] = contact_info['email']
|
471
528
|
end
|
529
|
+
# optional
|
530
|
+
app_info[:opt_link_name] = create_param.delete('link_name')
|
531
|
+
end
|
532
|
+
|
533
|
+
# Callback from Plugins::Node
|
534
|
+
def permissions_create_event(created_data:, app_info:)
|
535
|
+
event_creation = {
|
536
|
+
'types' => ['permission.created'],
|
537
|
+
'node_id' => app_info[:node_info]['id'],
|
538
|
+
'workspace_id' => app_info[:workspace_info]['id'],
|
539
|
+
'data' => created_data # Response from previous step
|
540
|
+
}
|
541
|
+
# (optional). The name of the folder to be displayed to the destination user. Use it if its value is different from the "share_as" field.
|
542
|
+
event_creation['link_name'] = app_info[:opt_link_name] unless app_info[:opt_link_name].nil?
|
543
|
+
create('events', event_creation)
|
472
544
|
end
|
473
545
|
end # AoC
|
474
546
|
end # Aspera
|