aspera-cli 4.10.0 → 4.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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