aspera-cli 4.9.0 → 4.11.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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/BUGS.md +20 -0
  4. data/CHANGELOG.md +509 -0
  5. data/CONTRIBUTING.md +118 -0
  6. data/README.md +1241 -916
  7. data/bin/ascli +4 -4
  8. data/bin/asession +11 -11
  9. data/docs/test_env.conf +32 -21
  10. data/examples/aoc.rb +4 -4
  11. data/examples/dascli +16 -9
  12. data/examples/faspex4.rb +8 -8
  13. data/examples/node.rb +12 -12
  14. data/examples/server.rb +10 -10
  15. data/lib/aspera/aoc.rb +273 -266
  16. data/lib/aspera/ascmd.rb +56 -54
  17. data/lib/aspera/ats_api.rb +4 -4
  18. data/lib/aspera/cli/basic_auth_plugin.rb +15 -12
  19. data/lib/aspera/cli/extended_value.rb +5 -5
  20. data/lib/aspera/cli/formater.rb +64 -64
  21. data/lib/aspera/cli/info.rb +2 -2
  22. data/lib/aspera/cli/listener/line_dump.rb +1 -1
  23. data/lib/aspera/cli/listener/logger.rb +1 -1
  24. data/lib/aspera/cli/listener/progress.rb +5 -6
  25. data/lib/aspera/cli/listener/progress_multi.rb +14 -19
  26. data/lib/aspera/cli/main.rb +66 -67
  27. data/lib/aspera/cli/manager.rb +112 -110
  28. data/lib/aspera/cli/plugin.rb +57 -36
  29. data/lib/aspera/cli/plugins/alee.rb +4 -4
  30. data/lib/aspera/cli/plugins/aoc.rb +309 -670
  31. data/lib/aspera/cli/plugins/ats.rb +44 -46
  32. data/lib/aspera/cli/plugins/bss.rb +10 -10
  33. data/lib/aspera/cli/plugins/config.rb +497 -378
  34. data/lib/aspera/cli/plugins/console.rb +12 -12
  35. data/lib/aspera/cli/plugins/cos.rb +18 -20
  36. data/lib/aspera/cli/plugins/faspex.rb +112 -114
  37. data/lib/aspera/cli/plugins/faspex5.rb +71 -46
  38. data/lib/aspera/cli/plugins/node.rb +379 -283
  39. data/lib/aspera/cli/plugins/orchestrator.rb +46 -46
  40. data/lib/aspera/cli/plugins/preview.rb +122 -114
  41. data/lib/aspera/cli/plugins/server.rb +137 -83
  42. data/lib/aspera/cli/plugins/shares.rb +30 -29
  43. data/lib/aspera/cli/plugins/sync.rb +13 -33
  44. data/lib/aspera/cli/transfer_agent.rb +60 -59
  45. data/lib/aspera/cli/version.rb +1 -1
  46. data/lib/aspera/colors.rb +3 -3
  47. data/lib/aspera/command_line_builder.rb +27 -27
  48. data/lib/aspera/cos_node.rb +22 -20
  49. data/lib/aspera/data_repository.rb +1 -1
  50. data/lib/aspera/environment.rb +35 -15
  51. data/lib/aspera/fasp/agent_base.rb +15 -15
  52. data/lib/aspera/fasp/agent_connect.rb +23 -21
  53. data/lib/aspera/fasp/agent_direct.rb +66 -64
  54. data/lib/aspera/fasp/agent_httpgw.rb +141 -78
  55. data/lib/aspera/fasp/agent_node.rb +23 -21
  56. data/lib/aspera/fasp/agent_trsdk.rb +20 -20
  57. data/lib/aspera/fasp/error.rb +3 -2
  58. data/lib/aspera/fasp/error_info.rb +11 -8
  59. data/lib/aspera/fasp/installation.rb +79 -79
  60. data/lib/aspera/fasp/listener.rb +1 -1
  61. data/lib/aspera/fasp/parameters.rb +86 -71
  62. data/lib/aspera/fasp/parameters.yaml +7 -4
  63. data/lib/aspera/fasp/resume_policy.rb +8 -8
  64. data/lib/aspera/fasp/transfer_spec.rb +35 -2
  65. data/lib/aspera/fasp/uri.rb +7 -7
  66. data/lib/aspera/faspex_gw.rb +7 -5
  67. data/lib/aspera/hash_ext.rb +3 -3
  68. data/lib/aspera/id_generator.rb +5 -5
  69. data/lib/aspera/keychain/encrypted_hash.rb +38 -105
  70. data/lib/aspera/keychain/macos_security.rb +128 -57
  71. data/lib/aspera/log.rb +7 -7
  72. data/lib/aspera/nagios.rb +19 -18
  73. data/lib/aspera/node.rb +209 -35
  74. data/lib/aspera/oauth.rb +37 -36
  75. data/lib/aspera/open_application.rb +19 -11
  76. data/lib/aspera/persistency_action_once.rb +4 -4
  77. data/lib/aspera/persistency_folder.rb +16 -15
  78. data/lib/aspera/preview/file_types.rb +8 -8
  79. data/lib/aspera/preview/generator.rb +67 -67
  80. data/lib/aspera/preview/utils.rb +27 -27
  81. data/lib/aspera/proxy_auto_config.js +41 -41
  82. data/lib/aspera/proxy_auto_config.rb +21 -14
  83. data/lib/aspera/rest.rb +72 -67
  84. data/lib/aspera/rest_call_error.rb +2 -1
  85. data/lib/aspera/rest_error_analyzer.rb +18 -17
  86. data/lib/aspera/rest_errors_aspera.rb +16 -16
  87. data/lib/aspera/secret_hider.rb +15 -13
  88. data/lib/aspera/ssh.rb +11 -10
  89. data/lib/aspera/sync.rb +158 -44
  90. data/lib/aspera/temp_file_manager.rb +2 -2
  91. data/lib/aspera/uri_reader.rb +4 -4
  92. data/lib/aspera/web_auth.rb +14 -13
  93. data.tar.gz.sig +0 -0
  94. metadata +11 -36
  95. 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(:aoc_pub_link,lambda{|o|
12
- o.api.call({
13
- operation: 'POST',
14
- subpath: o.gparams[:path_token],
15
- headers: {'Accept' => 'application/json'},
16
- json_params: o.sparams[:json],
17
- url_params: o.sparams[:url].merge(scope: o.gparams[:scope]) # scope is here because it changes over time (node)
11
+ Aspera::Oauth.register_token_creator(
12
+ :aoc_pub_link,
13
+ lambda{|o|
14
+ o.api.call({
15
+ operation: 'POST',
16
+ subpath: o.gparams[:path_token],
17
+ headers: {'Accept' => 'application/json'},
18
+ json_params: o.sparams[:json],
19
+ url_params: o.sparams[:url].merge(scope: o.gparams[:scope]) # scope is here because it changes over time (node)
20
+ })
21
+ },
22
+ lambda { |oauth|
23
+ return [oauth.sparams.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
- COOKIE_PREFIX = 'aspera.aoc'
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,:GLOBAL_CLIENT_APPS,:DATA_REPO_INDEX_START,:COOKIE_PREFIX,:PUBLIC_LINK_PATHS,:JWT_AUDIENCE,
44
- :OAUTH_API_SUBPATH,:USER_INFO_FIELDS_MIN
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 + '+' + SCOPE_FILES_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("instance_fqdn=#{instance_fqdn}")
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("instance_domain=#{instance_domain}")
83
- Log.log.debug("organization=#{organization}")
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,7 +119,7 @@ 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
125
  a_auth[:crtype] = :aoc_pub_link
@@ -127,60 +131,31 @@ 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("no expected format: #{public_link_url}")
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("redirect to: #{public_link_url}")
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
- # @param :link,:url,:auth,:client_id,:client_secret,:scope,:redirect_uri,:private_key,:passphrase,:username,:subpath,:password (for pub link)
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
- @key_chain = nil
183
- @user_info = nil
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}}
@@ -188,13 +163,13 @@ module Aspera
188
163
  aoc_auth_p = aoc_rest_p[:auth]
189
164
 
190
165
  # sets opt[:url], aoc_rest_p[:auth][:crtype], [:auth][:aoc_pub_link] if there is a link
191
- self.class.resolve_pub_link(aoc_auth_p,opt)
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.has_key?(:crtype)
210
- raise ArgumentError,'Missing mandatory option: auth' if opt[:auth].nil?
184
+ if !aoc_auth_p.key?(:crtype)
185
+ raise ArgumentError, 'Missing mandatory option: auth' if opt[:auth].nil?
211
186
  aoc_auth_p[:crtype] = 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
194
  case aoc_auth_p[:crtype]
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,7 +210,7 @@ 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]}
213
+ aoc_auth_p[:auth] = {type: :basic, username: aoc_auth_p[:client_id], password: aoc_auth_p[:client_secret]}
239
214
  else raise "ERROR: unsupported auth method: #{aoc_auth_p[:crtype]}"
240
215
  end
241
216
  super(aoc_rest_p)
@@ -243,219 +218,77 @@ module Aspera
243
218
 
244
219
  def url_token_data
245
220
  return nil unless params[:auth][:crtype].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
- return read('url_tokens')[:data].first
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 [user_info['id']] if url_token_data.nil?
229
+ return [] # TODO : url_token_data['id'] ?
248
230
  end
249
231
 
250
- def key_chain=(keychain)
251
- raise 'keychain already set' unless @key_chain.nil?
252
- raise 'keychain must have get_secret' unless keychain.respond_to?(:get_secret)
253
- @key_chain = keychain
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 user_info
258
- if @user_info.nil?
239
+ def user_info(exception: false)
240
+ if @cache_user_info.nil?
259
241
  # get our user's default information
260
- @user_info =
242
+ @cache_user_info =
261
243
  begin
262
244
  read('self')[:data]
263
245
  rescue StandardError => e
264
- Log.log.debug("ignoring error: #{e}")
246
+ raise e if exception
247
+ Log.log.debug{"ignoring error: #{e}"}
265
248
  {}
266
249
  end
267
- USER_INFO_FIELDS_MIN.each{|f|@user_info[f] = 'unknown' if @user_info[f].nil?}
268
- end
269
- return @user_info
270
- end
271
-
272
- # build ts addon for IBM Aspera Console (cookie)
273
- def console_ts(app)
274
- # we are sure that fields are not nil
275
- elements = [app,user_info['name'],user_info['email']].map{|e|Base64.strict_encode64(e)}
276
- elements.unshift(COOKIE_PREFIX)
277
- return {'cookie' => elements.join(':')}
278
- end
279
-
280
- # build "transfer info", 2 elements array with:
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
316
- 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]}
250
+ USER_INFO_FIELDS_MIN.each{|f|@cache_user_info[f] = 'unknown' if @cache_user_info[f].nil?}
324
251
  end
325
- # add caller provided transfer spec
326
- transfer_spec.deep_merge!(ts_add)
327
- # additional information for transfer agent
328
- source_and_token_generator = {
329
- src: :node_gen4,
330
- regenerate_token: token_generation_lambda
331
- }
332
- return transfer_spec,source_and_token_generator
252
+ return @cache_user_info
333
253
  end
334
254
 
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?
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_id_to_api(node_id:, plugin:, scope: nil, package_info: nil)
259
+ node_info = read("nodes/#{node_id}")[:data]
345
260
  node_rest_params = {base_url: node_info['url']}
346
261
  # if secret is available
347
- if !ak_secret.nil?
262
+ if scope.nil?
348
263
  node_rest_params[:auth] = {
349
264
  type: :basic,
350
265
  username: node_info['access_key'],
351
- password: ak_secret
266
+ password: @secret_finder&.lookup_secret(url: node_info['url'], username: node_info['access_key'], mandatory: true)
352
267
  }
353
268
  else
354
- # X-Aspera-AccessKey required for bearer token only
355
- node_rest_params[:headers] = {'X-Aspera-AccessKey' => node_info['access_key']}
269
+ # OAuth bearer token
356
270
  node_rest_params[:auth] = params[:auth].clone
357
- node_rest_params[:auth][:scope] = self.class.node_scope(node_info['access_key'],scope)
271
+ node_rest_params[:auth][:scope] = self.class.node_scope(node_info['access_key'], scope)
272
+ # special header required for bearer token only
273
+ node_rest_params[:headers] = {Aspera::Node::X_ASPERA_ACCESSKEY => node_info['access_key']}
358
274
  end
359
- return Node.new(node_rest_params)
360
- end
361
-
362
- # check that parameter has necessary types
363
- # @return split values
364
- def check_get_node_file(node_file)
365
- raise "node_file must be Hash (got #{node_file.class})" unless node_file.is_a?(Hash)
366
- raise 'node_file must have 2 keys: :file_id and :node_info' unless node_file.keys.sort.eql?(%i[file_id node_info])
367
- node_info = node_file[:node_info]
368
- file_id = node_file[:file_id]
369
- raise "node_info must be Hash (got #{node_info.class}: #{node_info})" unless node_info.is_a?(Hash)
370
- raise 'node_info must have id' unless node_info.has_key?('id')
371
- raise 'file_id is empty' if file_id.to_s.empty?
372
- return node_info,file_id
373
- end
374
-
375
- # add entry to list if test block is success
376
- def process_find_files(entry,path)
377
- begin
378
- # add to result if match filter
379
- @find_state[:found].push(entry.merge({'path' => path})) if @find_state[:test_block].call(entry)
380
- # process link
381
- if entry[:type].eql?('link')
382
- sub_node_info = read("nodes/#{entry['target_node_id']}")[:data]
383
- sub_opt = {method: process_find_files, top_file_id: entry['target_id'], top_file_path: path}
384
- get_node_api(sub_node_info).crawl(self,sub_opt)
385
- end
386
- rescue StandardError => e
387
- Log.log.error("#{path}: #{e.message}")
388
- end
389
- # process all folders
390
- return true
391
- end
392
-
393
- def find_files(top_node_file, test_block)
394
- top_node_info,top_file_id = check_get_node_file(top_node_file)
395
- Log.log.debug("find_files: node_info=#{top_node_info}, fileid=#{top_file_id}")
396
- @find_state = {found: [], test_block: test_block}
397
- get_node_api(top_node_info).crawl(self,{method: :process_find_files, top_file_id: top_file_id})
398
- result = @find_state[:found]
399
- @find_state = nil
400
- return result
401
- end
402
-
403
- def process_resolve_node_file(entry,_path)
404
- # stop digging here if not in right path
405
- return false unless entry['name'].eql?(@resolve_state[:path].first)
406
- # ok it matches, so we remove the match
407
- @resolve_state[:path].shift
408
- case entry['type']
409
- when 'file'
410
- # file must be terminal
411
- raise "#{entry['name']} is a file, expecting folder to find: #{@resolve_state[:path]}" unless @resolve_state[:path].empty?
412
- @resolve_state[:result][:file_id] = entry['id']
413
- when 'link'
414
- @resolve_state[:result][:node_info] = read("nodes/#{entry['target_node_id']}")[:data]
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
425
- end
426
- else
427
- Log.log.warn("unknown element type: #{entry['type']}")
428
- end
429
- # continue to dig folder
430
- return true
431
- end
432
-
433
- # @return Array(node_info,file_id) for the given path
434
- # @param top_node_file Array [root node,file id]
435
- # @param element_path_string String path of element
436
- # supports links to secondary nodes
437
- def resolve_node_file(top_node_file, element_path_string)
438
- top_node_info,top_file_id = check_get_node_file(top_node_file)
439
- path_elements = element_path_string.split(PATH_SEPARATOR).reject(&:empty?)
440
- result = {node_info: top_node_info, file_id: nil}
441
- if path_elements.empty?
442
- result[:file_id] = top_file_id
443
- else
444
- @resolve_state = {path: path_elements, result: result}
445
- get_node_api(top_node_info).crawl(self,{method: :process_resolve_node_file, top_file_id: top_file_id})
446
- not_found = @resolve_state[:path]
447
- @resolve_state = nil
448
- raise "entry not found: #{not_found}" if result[:file_id].nil?
449
- end
450
- return result
275
+ app_info = {
276
+ plugin: plugin,
277
+ node_info: node_info,
278
+ app: package_info.nil? ? FILES_APP : PACKAGES_APP,
279
+ api: self # for callback
280
+ }
281
+ app_info[:package_info] = package_info unless package_info.nil?
282
+ return Node.new(params: node_rest_params, app_info: app_info)
451
283
  end
452
284
 
285
+ # Query entity type by name and returns the id if a single entry only
453
286
  # @param entity_type path of entuty in API
454
287
  # @param entity_name name of searched entity
455
288
  # @param options additional search options
456
- def lookup_entity_by_name(entity_type,entity_name,options={})
289
+ def lookup_entity_by_name(entity_type, entity_name, options={})
457
290
  # returns entities whose name contains value (case insensitive)
458
- matching_items = read(entity_type,options.merge({'q' => CGI.escape(entity_name)}))[:data]
291
+ matching_items = read(entity_type, options.merge({'q' => CGI.escape(entity_name)}))[:data]
459
292
  case matching_items.length
460
293
  when 1 then return matching_items.first
461
294
  when 0 then raise %Q{#{ENTITY_NOT_FOUND} #{entity_type}: "#{entity_name}"}
@@ -470,5 +303,179 @@ module Aspera
470
303
  end
471
304
  end
472
305
  end
306
+
307
+ # Check metadata: remove when validation is done server side
308
+ def validate_metadata(pkg_data)
309
+ # validate only for shared inboxes
310
+ return unless pkg_data['recipients'].is_a?(Array) &&
311
+ pkg_data['recipients'].first.is_a?(Hash) &&
312
+ pkg_data['recipients'].first.key?('type') &&
313
+ pkg_data['recipients'].first['type'].eql?('dropbox')
314
+
315
+ shbx_kid = pkg_data['recipients'].first['id']
316
+ meta_schema = read("dropboxes/#{shbx_kid}")[:data]['metadata_schema']
317
+ if meta_schema.nil? || meta_schema.empty?
318
+ Log.log.debug('no metadata in shared inbox')
319
+ return
320
+ end
321
+ pkg_meta = pkg_data['metadata']
322
+ raise "package requires metadata: #{meta_schema}" unless pkg_data.key?('metadata')
323
+ raise 'metadata must be an Array' unless pkg_meta.is_a?(Array)
324
+ Log.dump(:metadata, pkg_meta)
325
+ pkg_meta.each do |field|
326
+ raise 'metadata field must be Hash' unless field.is_a?(Hash)
327
+ raise 'metadata field must have name' unless field.key?('name')
328
+ raise 'metadata field must have values' unless field.key?('values')
329
+ raise 'metadata values must be an Array' unless field['values'].is_a?(Array)
330
+ raise "unknown metadata field: #{field['name']}" if meta_schema.select{|i|i['name'].eql?(field['name'])}.empty?
331
+ end
332
+ meta_schema.each do |field|
333
+ provided = pkg_meta.select{|i|i['name'].eql?(field['name'])}
334
+ raise "only one field with name #{field['name']} allowed" if provided.count > 1
335
+ raise "missing mandatory field: #{field['name']}" if field['required'] && provided.empty?
336
+ end
337
+ end
338
+
339
+ # Normalize package creation recipient lists as expected by AoC API
340
+ # AoC expects {type: , id: }, but ascli allows providing either the native values or just a name
341
+ # in that case, the name is resolved and replaced with {type: , id: }
342
+ # @param package_data The whole package creation payload
343
+ # @param recipient_list_field The field in structure, i.e. recipients or bcc_recipients
344
+ # @return nil package_data is modified
345
+ def resolve_package_recipients(package_data, ws_id, recipient_list_field, new_user_option)
346
+ return unless package_data.key?(recipient_list_field)
347
+ raise CliBadArgument, "#{recipient_list_field} must be an Array" unless package_data[recipient_list_field].is_a?(Array)
348
+ new_user_option = {'package_contact' => true} if new_user_option.nil?
349
+ # list with resolved elements
350
+ resolved_list = []
351
+ package_data[recipient_list_field].each do |short_recipient_info|
352
+ case short_recipient_info
353
+ when Hash # native API information, check keys
354
+ raise "#{recipient_list_field} element shall have fields: id and type" unless short_recipient_info.keys.sort.eql?(%w[id type])
355
+ when String # CLI helper: need to resolve provided name to type/id
356
+ # email: user, else dropbox
357
+ entity_type = short_recipient_info.include?('@') ? 'contacts' : 'dropboxes'
358
+ begin
359
+ full_recipient_info = lookup_entity_by_name(entity_type, short_recipient_info, {'current_workspace_id' => ws_id})
360
+ rescue RuntimeError => e
361
+ raise e unless e.message.start_with?(ENTITY_NOT_FOUND)
362
+ # dropboxes cannot be created on the fly
363
+ raise "No such shared inbox in workspace #{ws_id}" if entity_type.eql?('dropboxes')
364
+ # unknown user: create it as external user
365
+ full_recipient_info = create('contacts', {
366
+ 'current_workspace_id' => ws_id,
367
+ 'email' => short_recipient_info}.merge(new_user_option))[:data]
368
+ end
369
+ short_recipient_info = if entity_type.eql?('dropboxes')
370
+ {'id' => full_recipient_info['id'], 'type' => 'dropbox'}
371
+ else
372
+ {'id' => full_recipient_info['source_id'], 'type' => full_recipient_info['source_type']}
373
+ end
374
+ else # unexpected extended value, must be String or Hash
375
+ raise "#{recipient_list_field} item must be a String (email, shared inbox) or Hash (id,type)"
376
+ end # type of recipient info
377
+ # add original or resolved recipient info
378
+ resolved_list.push(short_recipient_info)
379
+ end
380
+ # replace with resolved elements
381
+ package_data[recipient_list_field] = resolved_list
382
+ return nil
383
+ end
384
+
385
+ # Add transferspec
386
+ # callback in Aspera::Node (transfer_spec_gen4)
387
+ def add_ts_tags(transfer_spec:, app_info:)
388
+ # translate transfer direction to upload/download
389
+ transfer_type = Fasp::TransferSpec.action(transfer_spec)
390
+ # Analytics tags
391
+ ################
392
+ ws_info = app_info[:plugin].current_workspace_info
393
+ transfer_spec.deep_merge!({
394
+ 'tags' => {
395
+ 'aspera' => {
396
+ 'usage_id' => "aspera.files.workspace.#{ws_info['id']}", # activity tracking
397
+ 'files' => {
398
+ 'files_transfer_action' => "#{transfer_type}_#{app_info[:app].gsub(/s$/, '')}",
399
+ 'workspace_name' => ws_info['name'], # activity tracking
400
+ 'workspace_id' => ws_info['id']
401
+ }
402
+ }
403
+ }
404
+ })
405
+ # Console cookie
406
+ ################
407
+ # we are sure that fields are not nil
408
+ cookie_elements = [app_info[:app], user_info['name'], user_info['email']].map{|e|Base64.strict_encode64(e)}
409
+ cookie_elements.unshift(COOKIE_PREFIX_CONSOLE_AOC)
410
+ transfer_spec['cookie'] = cookie_elements.join(':')
411
+ # Application tags
412
+ ##################
413
+ case app_info[:app]
414
+ when FILES_APP
415
+ file_id = transfer_spec['tags']['aspera']['node']['file_id']
416
+ transfer_spec.deep_merge!({'tags' => {'aspera' => {'files' => {'parentCwd' => "#{app_info[:node_info]['id']}:#{file_id}"}}}}) \
417
+ unless transfer_spec.key?('remote_access_key')
418
+ when PACKAGES_APP
419
+ transfer_spec.deep_merge!({
420
+ 'tags' => {
421
+ 'aspera' => {
422
+ 'files' => {
423
+ 'package_id' => app_info[:package_info]['id'],
424
+ 'package_name' => app_info[:package_info]['name'],
425
+ 'package_operation' => transfer_type
426
+ }}}})
427
+ end
428
+ transfer_spec['tags']['aspera']['files']['node_id'] = app_info[:node_info]['id']
429
+ transfer_spec['tags']['aspera']['app'] = app_info[:app]
430
+ end
431
+
432
+ ID_AK_ADMIN = 'ASPERA_ACCESS_KEY_ADMIN'
433
+ # Callback from Plugins::Node
434
+ def permissions_create_params(create_param:, app_info:)
435
+ # workspace shared folder:
436
+ # access_id = "#{ID_AK_ADMIN}_WS_#{ app_info[:plugin].current_workspace_info['id']}"
437
+ default_params = {
438
+ # 'access_type' => 'user', # mandatory: user or group
439
+ # 'access_id' => access_id, # id of user or group
440
+ 'tags' => {
441
+ 'aspera' => {
442
+ 'files' => {
443
+ 'workspace' => {
444
+ 'id' => app_info[:plugin].current_workspace_info['id'],
445
+ 'workspace_name' => app_info[:plugin].current_workspace_info['name'],
446
+ 'user_name' => user_info['name'],
447
+ 'shared_by_user_id' => user_info['id'],
448
+ 'shared_by_name' => user_info['name'],
449
+ 'shared_by_email' => user_info['email'],
450
+ # 'shared_with_name' => access_id,
451
+ 'access_key' => app_info[:node_info]['access_key'],
452
+ 'node' => app_info[:node_info]['name']}}}}}
453
+ create_param.deep_merge!(default_params)
454
+ if create_param.key?('with')
455
+ contact_info = lookup_entity_by_name(
456
+ 'contacts',
457
+ create_param['with'],
458
+ {'current_workspace_id' => app_info[:plugin].current_workspace_info['id'], 'context' => 'share_folder'})
459
+ create_param.delete('with')
460
+ create_param['access_type'] = contact_info['source_type']
461
+ create_param['access_id'] = contact_info['source_id']
462
+ create_param['tags']['aspera']['files']['workspace']['shared_with_name'] = contact_info['email']
463
+ end
464
+ # optionnal
465
+ app_info[:opt_link_name] = create_param.delete('link_name')
466
+ end
467
+
468
+ # Callback from Plugins::Node
469
+ def permissions_create_event(created_data:, app_info:)
470
+ event_creation = {
471
+ 'types' => ['permission.created'],
472
+ 'node_id' => app_info[:node_info]['id'],
473
+ 'workspace_id' => app_info[:plugin].current_workspace_info['id'],
474
+ 'data' => created_data # Response from previous step
475
+ }
476
+ # (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.
477
+ event_creation['link_name'] = app_info[:opt_link_name] unless app_info[:opt_link_name].nil?
478
+ create('events', event_creation)
479
+ end
473
480
  end # AoC
474
481
  end # Aspera