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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/BUGS.md +19 -0
  4. data/CHANGELOG.md +528 -0
  5. data/CONTRIBUTING.md +143 -0
  6. data/README.md +977 -589
  7. data/bin/ascli +4 -4
  8. data/bin/asession +12 -12
  9. data/docs/test_env.conf +29 -19
  10. data/examples/aoc.rb +6 -6
  11. data/examples/dascli +18 -16
  12. data/examples/faspex4.rb +15 -15
  13. data/examples/node.rb +12 -12
  14. data/examples/proxy.pac +2 -2
  15. data/examples/server.rb +12 -12
  16. data/lib/aspera/aoc.rb +344 -272
  17. data/lib/aspera/ascmd.rb +56 -54
  18. data/lib/aspera/ats_api.rb +4 -4
  19. data/lib/aspera/cli/basic_auth_plugin.rb +15 -12
  20. data/lib/aspera/cli/extended_value.rb +9 -9
  21. data/lib/aspera/cli/{formater.rb → formatter.rb} +69 -69
  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 +16 -21
  26. data/lib/aspera/cli/main.rb +72 -73
  27. data/lib/aspera/cli/manager.rb +112 -112
  28. data/lib/aspera/cli/plugin.rb +68 -48
  29. data/lib/aspera/cli/plugins/alee.rb +4 -4
  30. data/lib/aspera/cli/plugins/aoc.rb +322 -720
  31. data/lib/aspera/cli/plugins/ats.rb +50 -52
  32. data/lib/aspera/cli/plugins/bss.rb +10 -10
  33. data/lib/aspera/cli/plugins/config.rb +514 -410
  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 +134 -136
  37. data/lib/aspera/cli/plugins/faspex5.rb +235 -70
  38. data/lib/aspera/cli/plugins/node.rb +378 -309
  39. data/lib/aspera/cli/plugins/orchestrator.rb +52 -49
  40. data/lib/aspera/cli/plugins/preview.rb +129 -120
  41. data/lib/aspera/cli/plugins/server.rb +137 -83
  42. data/lib/aspera/cli/plugins/shares.rb +77 -52
  43. data/lib/aspera/cli/plugins/sync.rb +13 -33
  44. data/lib/aspera/cli/transfer_agent.rb +61 -61
  45. data/lib/aspera/cli/version.rb +2 -1
  46. data/lib/aspera/colors.rb +3 -3
  47. data/lib/aspera/command_line_builder.rb +78 -74
  48. data/lib/aspera/cos_node.rb +31 -29
  49. data/lib/aspera/data_repository.rb +1 -1
  50. data/lib/aspera/environment.rb +30 -28
  51. data/lib/aspera/fasp/agent_base.rb +17 -15
  52. data/lib/aspera/fasp/agent_connect.rb +34 -32
  53. data/lib/aspera/fasp/agent_direct.rb +70 -73
  54. data/lib/aspera/fasp/agent_httpgw.rb +79 -74
  55. data/lib/aspera/fasp/agent_node.rb +26 -26
  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 +80 -80
  60. data/lib/aspera/fasp/listener.rb +2 -2
  61. data/lib/aspera/fasp/parameters.rb +103 -92
  62. data/lib/aspera/fasp/parameters.yaml +313 -214
  63. data/lib/aspera/fasp/resume_policy.rb +10 -10
  64. data/lib/aspera/fasp/transfer_spec.rb +22 -2
  65. data/lib/aspera/fasp/uri.rb +7 -7
  66. data/lib/aspera/faspex_gw.rb +80 -159
  67. data/lib/aspera/faspex_postproc.rb +77 -0
  68. data/lib/aspera/hash_ext.rb +3 -3
  69. data/lib/aspera/id_generator.rb +5 -5
  70. data/lib/aspera/keychain/encrypted_hash.rb +23 -28
  71. data/lib/aspera/keychain/macos_security.rb +21 -20
  72. data/lib/aspera/log.rb +13 -13
  73. data/lib/aspera/nagios.rb +24 -23
  74. data/lib/aspera/node.rb +217 -38
  75. data/lib/aspera/oauth.rb +78 -74
  76. data/lib/aspera/open_application.rb +19 -11
  77. data/lib/aspera/persistency_action_once.rb +4 -4
  78. data/lib/aspera/persistency_folder.rb +13 -13
  79. data/lib/aspera/preview/file_types.rb +8 -8
  80. data/lib/aspera/preview/generator.rb +67 -67
  81. data/lib/aspera/preview/utils.rb +27 -27
  82. data/lib/aspera/proxy_auto_config.js +63 -63
  83. data/lib/aspera/proxy_auto_config.rb +19 -19
  84. data/lib/aspera/rest.rb +65 -67
  85. data/lib/aspera/rest_call_error.rb +2 -1
  86. data/lib/aspera/rest_error_analyzer.rb +22 -21
  87. data/lib/aspera/rest_errors_aspera.rb +16 -16
  88. data/lib/aspera/secret_hider.rb +17 -14
  89. data/lib/aspera/ssh.rb +15 -14
  90. data/lib/aspera/sync.rb +177 -62
  91. data/lib/aspera/temp_file_manager.rb +2 -2
  92. data/lib/aspera/uri_reader.rb +4 -4
  93. data/lib/aspera/web_auth.rb +13 -64
  94. data/lib/aspera/web_server_simple.rb +76 -0
  95. data.tar.gz.sig +0 -0
  96. metadata +11 -6
  97. 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.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
- 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,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[:crtype] = :aoc_pub_link
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("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}}
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][:crtype], [:auth][:aoc_pub_link] if there is a link
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.has_key?(:crtype)
210
- raise ArgumentError,'Missing mandatory option: auth' if opt[:auth].nil?
211
- aoc_auth_p[:crtype] = opt[:auth]
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[:crtype]
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[:crtype]}"
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][:crtype].eql?(:aoc_pub_link)
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
- 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 [current_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 current_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?}
250
+ USER_INFO_FIELDS_MIN.each{|f|@cache_user_info[f] = 'unknown' if @cache_user_info[f].nil?}
268
251
  end
269
- return @user_info
252
+ return @cache_user_info
270
253
  end
271
254
 
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
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
- # 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
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 !ak_secret.nil?
276
+ if scope.nil?
348
277
  node_rest_params[:auth] = {
349
278
  type: :basic,
350
279
  username: node_info['access_key'],
351
- password: ak_secret
280
+ password: @secret_finder&.lookup_secret(url: node_info['url'], username: node_info['access_key'], mandatory: true)
352
281
  }
353
282
  else
354
- # X-Aspera-AccessKey required for bearer token only
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
- return Node.new(node_rest_params)
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
- # 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
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
- # 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}")
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
- 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
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
- 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
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
- else
427
- Log.log.warn("unknown element type: #{entry['type']}")
411
+ pkg_data['metadata'] = api_meta
412
+ else raise "metadata field if not of expected type: #{pkg_meta.class}"
428
413
  end
429
- # continue to dig folder
430
- return true
414
+ return nil
431
415
  end
432
416
 
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?
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
- return result
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
- # @param entity_type path of entuty in API
454
- # @param entity_name name of searched entity
455
- # @param options additional search options
456
- def lookup_entity_by_name(entity_type,entity_name,options={})
457
- # returns entities whose name contains value (case insensitive)
458
- matching_items = read(entity_type,options.merge({'q' => CGI.escape(entity_name)}))[:data]
459
- case matching_items.length
460
- when 1 then return matching_items.first
461
- when 0 then raise %Q{#{ENTITY_NOT_FOUND} #{entity_type}: "#{entity_name}"}
462
- else
463
- # multiple case insensitive partial matches, try case insensitive full match
464
- # (anyway AoC does not allow creation of 2 entities with same case insensitive name)
465
- icase_matches = matching_items.select{|i|i['name'].casecmp?(entity_name)}
466
- case icase_matches.length
467
- when 1 then return icase_matches.first
468
- 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
469
- else raise "Two entities cannot have the same case insensitive name: #{icase_matches.map{|i|i['name']}}"
470
- end
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