aspera-cli 4.10.0 → 4.12.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|