aspera-cli 4.4.0 → 4.7.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. data/README.md +2095 -1503
  3. data/bin/ascli +2 -1
  4. data/bin/asession +4 -5
  5. data/docs/test_env.conf +3 -0
  6. data/examples/aoc.rb +4 -3
  7. data/examples/faspex4.rb +25 -25
  8. data/examples/proxy.pac +1 -1
  9. data/examples/transfer.rb +17 -17
  10. data/lib/aspera/aoc.rb +238 -185
  11. data/lib/aspera/ascmd.rb +93 -83
  12. data/lib/aspera/ats_api.rb +11 -10
  13. data/lib/aspera/cli/basic_auth_plugin.rb +13 -14
  14. data/lib/aspera/cli/extended_value.rb +42 -33
  15. data/lib/aspera/cli/formater.rb +142 -108
  16. data/lib/aspera/cli/info.rb +17 -0
  17. data/lib/aspera/cli/listener/line_dump.rb +3 -2
  18. data/lib/aspera/cli/listener/logger.rb +2 -1
  19. data/lib/aspera/cli/listener/progress.rb +16 -18
  20. data/lib/aspera/cli/listener/progress_multi.rb +18 -21
  21. data/lib/aspera/cli/main.rb +173 -149
  22. data/lib/aspera/cli/manager.rb +163 -168
  23. data/lib/aspera/cli/plugin.rb +43 -31
  24. data/lib/aspera/cli/plugins/alee.rb +6 -6
  25. data/lib/aspera/cli/plugins/aoc.rb +405 -370
  26. data/lib/aspera/cli/plugins/ats.rb +86 -79
  27. data/lib/aspera/cli/plugins/bss.rb +14 -16
  28. data/lib/aspera/cli/plugins/config.rb +580 -362
  29. data/lib/aspera/cli/plugins/console.rb +23 -19
  30. data/lib/aspera/cli/plugins/cos.rb +18 -18
  31. data/lib/aspera/cli/plugins/faspex.rb +201 -158
  32. data/lib/aspera/cli/plugins/faspex5.rb +80 -57
  33. data/lib/aspera/cli/plugins/node.rb +183 -166
  34. data/lib/aspera/cli/plugins/orchestrator.rb +71 -67
  35. data/lib/aspera/cli/plugins/preview.rb +92 -96
  36. data/lib/aspera/cli/plugins/server.rb +79 -75
  37. data/lib/aspera/cli/plugins/shares.rb +35 -19
  38. data/lib/aspera/cli/plugins/sync.rb +20 -22
  39. data/lib/aspera/cli/transfer_agent.rb +76 -113
  40. data/lib/aspera/cli/version.rb +2 -1
  41. data/lib/aspera/colors.rb +35 -27
  42. data/lib/aspera/command_line_builder.rb +48 -34
  43. data/lib/aspera/cos_node.rb +29 -21
  44. data/lib/aspera/data_repository.rb +3 -2
  45. data/lib/aspera/environment.rb +50 -45
  46. data/lib/aspera/fasp/{manager.rb → agent_base.rb} +28 -25
  47. data/lib/aspera/fasp/{connect.rb → agent_connect.rb} +52 -43
  48. data/lib/aspera/fasp/{local.rb → agent_direct.rb} +58 -72
  49. data/lib/aspera/fasp/{http_gw.rb → agent_httpgw.rb} +37 -43
  50. data/lib/aspera/fasp/{node.rb → agent_node.rb} +35 -16
  51. data/lib/aspera/fasp/agent_trsdk.rb +104 -0
  52. data/lib/aspera/fasp/error.rb +2 -1
  53. data/lib/aspera/fasp/error_info.rb +68 -52
  54. data/lib/aspera/fasp/installation.rb +152 -124
  55. data/lib/aspera/fasp/listener.rb +1 -0
  56. data/lib/aspera/fasp/parameters.rb +87 -92
  57. data/lib/aspera/fasp/parameters.yaml +305 -249
  58. data/lib/aspera/fasp/resume_policy.rb +11 -14
  59. data/lib/aspera/fasp/transfer_spec.rb +26 -0
  60. data/lib/aspera/fasp/uri.rb +22 -21
  61. data/lib/aspera/faspex_gw.rb +55 -89
  62. data/lib/aspera/hash_ext.rb +4 -3
  63. data/lib/aspera/id_generator.rb +8 -7
  64. data/lib/aspera/keychain/encrypted_hash.rb +121 -0
  65. data/lib/aspera/keychain/macos_security.rb +90 -0
  66. data/lib/aspera/log.rb +55 -37
  67. data/lib/aspera/nagios.rb +13 -12
  68. data/lib/aspera/node.rb +30 -25
  69. data/lib/aspera/oauth.rb +175 -226
  70. data/lib/aspera/open_application.rb +4 -3
  71. data/lib/aspera/persistency_action_once.rb +6 -6
  72. data/lib/aspera/persistency_folder.rb +5 -9
  73. data/lib/aspera/preview/file_types.rb +6 -5
  74. data/lib/aspera/preview/generator.rb +25 -24
  75. data/lib/aspera/preview/options.rb +16 -14
  76. data/lib/aspera/preview/utils.rb +98 -98
  77. data/lib/aspera/{proxy_auto_config.erb.js → proxy_auto_config.js} +23 -31
  78. data/lib/aspera/proxy_auto_config.rb +111 -20
  79. data/lib/aspera/rest.rb +154 -135
  80. data/lib/aspera/rest_call_error.rb +2 -2
  81. data/lib/aspera/rest_error_analyzer.rb +23 -25
  82. data/lib/aspera/rest_errors_aspera.rb +15 -14
  83. data/lib/aspera/ssh.rb +12 -10
  84. data/lib/aspera/sync.rb +42 -41
  85. data/lib/aspera/temp_file_manager.rb +18 -14
  86. data/lib/aspera/timer_limiter.rb +2 -1
  87. data/lib/aspera/uri_reader.rb +7 -5
  88. data/lib/aspera/web_auth.rb +79 -76
  89. metadata +116 -29
  90. data/docs/Makefile +0 -66
  91. data/docs/README.erb.md +0 -3973
  92. data/docs/README.md +0 -13
  93. data/docs/diagrams.txt +0 -49
  94. data/docs/doc_tools.rb +0 -58
  95. data/lib/aspera/api_detector.rb +0 -60
  96. data/lib/aspera/cli/plugins/shares2.rb +0 -114
  97. data/lib/aspera/secrets.rb +0 -20
data/lib/aspera/aoc.rb CHANGED
@@ -1,38 +1,43 @@
1
+ # frozen_string_literal: true
1
2
  require 'aspera/log'
2
3
  require 'aspera/rest'
3
4
  require 'aspera/hash_ext'
4
5
  require 'aspera/data_repository'
5
- require 'aspera/node'
6
+ require 'aspera/fasp/transfer_spec'
6
7
  require 'base64'
7
8
 
9
+ Aspera::Oauth.register_token_creator(:aoc_pub_link,lambda{|o|
10
+ o.token_auth_api.call({
11
+ operation: 'POST',
12
+ subpath: o.params[:path_token],
13
+ headers: {'Accept'=>'application/json'},
14
+ json_params: o.params[:aoc_pub_link][:json],
15
+ url_params: o.params[:aoc_pub_link][:url].merge(scope: o.params[:scope]) # scope is here because it changes over time (node)
16
+ })
17
+ })
18
+
8
19
  module Aspera
9
20
  class AoC < Rest
10
- private
11
- @@use_standard_ports = true
12
-
13
- API_V1='api/v1'
14
-
15
21
  PRODUCT_NAME='Aspera on Cloud'
16
22
  # Production domain of AoC
17
23
  PROD_DOMAIN='ibmaspera.com'
18
24
  # to avoid infinite loop in pub link redirection
19
25
  MAX_REDIRECT=10
20
- CLIENT_APPS=['aspera.global-cli-client','aspera.drive']
26
+ # Well-known AoC globals client apps
27
+ GLOBAL_CLIENT_APPS=['aspera.global-cli-client','aspera.drive']
28
+ # index offset in data repository of client app
21
29
  DATA_REPO_INDEX_START = 4
22
-
30
+ # cookie prefix so that console can decode identity
31
+ COOKIE_PREFIX='aspera.aoc'
23
32
  # path in URL of public links
24
- PATHS_PUBLIC_LINK=['/packages/public/receive','/packages/public/send','/files/public']
33
+ PUBLIC_LINK_PATHS=['/packages/public/receive','/packages/public/send','/files/public']
25
34
  JWT_AUDIENCE='https://api.asperafiles.com/api/v1/oauth2/token'
26
35
  OAUTH_API_SUBPATH='api/v1/oauth2'
27
- DEFAULT_TSPEC_INFO={
28
- 'remote_user' => Node::ACCESS_KEY_TRANSFER_USER,
29
- 'ssh_port' => Node::SSH_PORT_DEFAULT,
30
- 'fasp_port' => Node::UDP_PORT_DEFAULT
31
- }
36
+ # minimum fields for user info if retrieval fails
37
+ USER_INFO_FIELDS_MIN=['name','email','id','default_workspace_id','organization_id']
32
38
 
33
- private_constant :PRODUCT_NAME,:PROD_DOMAIN,:MAX_REDIRECT,:CLIENT_APPS,:PATHS_PUBLIC_LINK,:JWT_AUDIENCE,:OAUTH_API_SUBPATH,:DEFAULT_TSPEC_INFO
39
+ private_constant :MAX_REDIRECT,:GLOBAL_CLIENT_APPS,:DATA_REPO_INDEX_START,:COOKIE_PREFIX,:PUBLIC_LINK_PATHS,:JWT_AUDIENCE,:OAUTH_API_SUBPATH,:USER_INFO_FIELDS_MIN
34
40
 
35
- public
36
41
  # various API scopes supported
37
42
  SCOPE_FILES_SELF='self'
38
43
  SCOPE_FILES_USER='user:all'
@@ -44,109 +49,142 @@ module Aspera
44
49
  PATH_SEPARATOR='/'
45
50
  FILES_APP='files'
46
51
  PACKAGES_APP='packages'
52
+ API_V1='api/v1'
47
53
 
48
- def self.get_client_info(client_name=CLIENT_APPS.first)
49
- client_index=CLIENT_APPS.index(client_name)
50
- raise "no such pre-defined client: #{client_name}" if client_index.nil?
51
- # strings /Applications/Aspera\ Drive.app/Contents/MacOS/AsperaDrive|grep -E '.{100}==$'|base64 --decode
52
- return client_name,Base64.urlsafe_encode64(DataRepository.instance.get_bin(DATA_REPO_INDEX_START+client_index))
53
- end
54
+ # class instance variable, access with accessors on class
55
+ @use_standard_ports = true
54
56
 
55
- # @param url of AoC instance
56
- # @return organization id in url and AoC domain: ibmaspera.com, asperafiles.com or qa.asperafiles.com, etc...
57
- def self.parse_url(aoc_org_url)
58
- uri=URI.parse(aoc_org_url.gsub(/\/+$/,''))
59
- instance_fqdn=uri.host
60
- Log.log.debug("instance_fqdn=#{instance_fqdn}")
61
- raise "No host found in URL.Please check URL format: https://myorg.#{PROD_DOMAIN}" if instance_fqdn.nil?
62
- organization,instance_domain=instance_fqdn.split('.',2)
63
- Log.log.debug("instance_domain=#{instance_domain}")
64
- Log.log.debug("organization=#{organization}")
65
- raise "expecting a public FQDN for #{PRODUCT_NAME}" if instance_domain.nil?
66
- return organization,instance_domain
67
- end
57
+ # class static methods
58
+ class << self
59
+ attr_accessor :use_standard_ports
60
+ # strings /Applications/Aspera\ Drive.app/Contents/MacOS/AsperaDrive|grep -E '.{100}==$'|base64 --decode
61
+ def get_client_info(client_name=GLOBAL_CLIENT_APPS.first)
62
+ client_index=GLOBAL_CLIENT_APPS.index(client_name)
63
+ raise "no such pre-defined client: #{client_name}" if client_index.nil?
64
+ return client_name,Base64.urlsafe_encode64(DataRepository.instance.data(DATA_REPO_INDEX_START+client_index))
65
+ end
68
66
 
69
- # base API url depends on domain, which could be "qa.xxx"
70
- def self.api_base_url(api_domain=PROD_DOMAIN)
71
- return "https://api.#{api_domain}"
72
- end
67
+ # @param url of AoC instance
68
+ # @return organization id in url and AoC domain: ibmaspera.com, asperafiles.com or qa.asperafiles.com, etc...
69
+ def parse_url(aoc_org_url)
70
+ uri=URI.parse(aoc_org_url.gsub(/\/+$/,''))
71
+ instance_fqdn=uri.host
72
+ Log.log.debug("instance_fqdn=#{instance_fqdn}")
73
+ raise "No host found in URL.Please check URL format: https://myorg.#{PROD_DOMAIN}" if instance_fqdn.nil?
74
+ organization,instance_domain=instance_fqdn.split('.',2)
75
+ Log.log.debug("instance_domain=#{instance_domain}")
76
+ Log.log.debug("organization=#{organization}")
77
+ raise "expecting a public FQDN for #{PRODUCT_NAME}" if instance_domain.nil?
78
+ return organization,instance_domain
79
+ end
73
80
 
74
- def self.metering_api(entitlement_id,customer_id,api_domain=PROD_DOMAIN)
75
- return Rest.new({
76
- :base_url => "#{api_base_url(api_domain)}/metering/v1",
77
- :headers => {'X-Aspera-Entitlement-Authorization' => Rest.basic_creds(entitlement_id,customer_id)}
78
- })
79
- end
81
+ # base API url depends on domain, which could be "qa.xxx"
82
+ def api_base_url(api_domain=PROD_DOMAIN)
83
+ return "https://api.#{api_domain}"
84
+ end
80
85
 
81
- # node API scopes
82
- def self.node_scope(access_key,scope)
83
- return 'node.'+access_key+':'+scope
84
- end
86
+ def metering_api(entitlement_id,customer_id,api_domain=PROD_DOMAIN)
87
+ return Rest.new({
88
+ base_url: "#{api_base_url(api_domain)}/metering/v1",
89
+ headers: {'X-Aspera-Entitlement-Authorization' => Rest.basic_creds(entitlement_id,customer_id)}
90
+ })
91
+ end
85
92
 
86
- def self.set_use_default_ports(val)
87
- @@use_standard_ports=val
88
- end
93
+ # node API scopes
94
+ def node_scope(access_key,scope)
95
+ return "node.#{access_key}:#{scope}"
96
+ end
89
97
 
90
- # check option "link"
91
- # if present try to get token value (resolve redirection if short links used)
92
- # then set options url/token/auth
93
- def self.resolve_pub_link(rest_opts,public_link_url)
94
- return if public_link_url.nil?
95
- # set to token if available after redirection
96
- url_param_token_pair=nil
97
- redirect_count=0
98
- loop do
99
- uri=URI.parse(public_link_url)
100
- if PATHS_PUBLIC_LINK.include?(uri.path)
101
- url_param_token_pair=URI::decode_www_form(uri.query).select{|e|e.first.eql?('token')}.first
102
- if url_param_token_pair.nil?
103
- raise ArgumentError,"link option must be URL with 'token' parameter"
98
+ # check option "link"
99
+ # if present try to get token value (resolve redirection if short links used)
100
+ # then set options url/token/auth
101
+ def resolve_pub_link(a_auth,a_opt)
102
+ public_link_url=a_opt[:link]
103
+ return if public_link_url.nil?
104
+ raise 'do not use both link and url options' unless a_opt[:url].nil?
105
+ redirect_count=0
106
+ while redirect_count <= MAX_REDIRECT
107
+ uri=URI.parse(public_link_url)
108
+ # detect if it's an expected format
109
+ if PUBLIC_LINK_PATHS.include?(uri.path)
110
+ url_param_token_pair=URI.decode_www_form(uri.query).select{|e|e.first.eql?('token')}.first
111
+ raise ArgumentError,'link option must be URL with "token" parameter' if url_param_token_pair.nil?
112
+ # ok we get it !
113
+ a_opt[:url]='https://'+uri.host
114
+ a_auth[:crtype]=:aoc_pub_link
115
+ a_auth[:aoc_pub_link]={
116
+ url: {grant_type: 'url_token'}, # URL args
117
+ json: {url_token: url_param_token_pair.last} # JSON body
118
+ }
119
+ # password protection of link
120
+ a_auth[:aoc_pub_link][:json][:password]=a_opt[:password] unless a_opt[:password].nil?
121
+ return # SUCCESS
104
122
  end
105
- # ok we get it !
106
- rest_opts[:org_url]='https://'+uri.host
107
- rest_opts[:auth][:grant]=:url_token
108
- rest_opts[:auth][:url_token]=url_param_token_pair.last
109
- return
110
- end
111
- Log.log.debug("no expected format: #{public_link_url}")
112
- raise "exceeded max redirection: #{MAX_REDIRECT}" if redirect_count > MAX_REDIRECT
113
- r = Net::HTTP.get_response(uri)
114
- if r.code.start_with?("3")
123
+ Log.log.debug("no expected format: #{public_link_url}")
124
+ r = Net::HTTP.get_response(uri)
125
+ # not a redirection
126
+ raise ArgumentError,'link option must be redirect or have token parameter' unless r.code.start_with?('3')
115
127
  public_link_url = r['location']
116
- raise "no location in redirection" if public_link_url.nil?
128
+ raise 'no location in redirection' if public_link_url.nil?
117
129
  Log.log.debug("redirect to: #{public_link_url}")
118
- else
119
- # not a redirection
120
- raise ArgumentError,'link option must be redirect or have token parameter'
130
+ end # loop
131
+ raise "exceeded max redirection: #{MAX_REDIRECT}"
132
+ end
133
+
134
+ # additional transfer spec (tags) for package information
135
+ def package_tags(package_info,operation)
136
+ return {'tags'=>{'aspera'=>{'files'=>{
137
+ 'package_id' => package_info['id'],
138
+ 'package_name' => package_info['name'],
139
+ 'package_operation' => operation
140
+ }}}}
141
+ end
142
+
143
+ # add details to show in analytics
144
+ def analytics_ts(app,direction,ws_id,ws_name)
145
+ # translate transfer to operation
146
+ operation=
147
+ case direction
148
+ when Fasp::TransferSpec::DIRECTION_SEND then 'upload'
149
+ when Fasp::TransferSpec::DIRECTION_RECEIVE then 'download'
150
+ else raise "ERROR: unexpected value: #{direction}"
121
151
  end
122
- end # loop
123
152
 
124
- raise RuntimeError,'too many redirections'
125
- end
153
+ return {
154
+ 'tags' => {
155
+ 'aspera' => {
156
+ 'usage_id' => "aspera.files.workspace.#{ws_id}", # activity tracking
157
+ 'files' => {
158
+ 'files_transfer_action' => "#{operation}_#{app.gsub(/s$/,'')}",
159
+ 'workspace_name' => ws_name, # activity tracking
160
+ 'workspace_id' => ws_id
161
+ }
162
+ }
163
+ }
164
+ }
165
+ end
166
+ end # static methods
126
167
 
127
168
  # @param :link,:url,:auth,:client_id,:client_secret,:scope,:redirect_uri,:private_key,:username,:subpath,:password (for pub link)
128
169
  def initialize(opt)
170
+ raise ArgumentError,'Missing mandatory option: scope' if opt[:scope].nil?
171
+
129
172
  # access key secrets are provided out of band to get node api access
130
173
  # key: access key
131
174
  # value: associated secret
132
175
  @key_chain=nil
176
+ @user_info=nil
133
177
 
134
178
  # init rest params
135
- aoc_rest_p={:auth=>{:type =>:oauth2}}
179
+ aoc_rest_p={auth: {type: :oauth2}}
136
180
  # shortcut to auth section
137
181
  aoc_auth_p=aoc_rest_p[:auth]
138
182
 
139
- # sets [:org_url], [:auth][:grant], [:auth][:url_token]
140
- self.class.resolve_pub_link(aoc_rest_p,opt[:link])
183
+ # sets opt[:url], aoc_rest_p[:auth][:crtype], [:auth][:aoc_pub_link] if there is a link
184
+ self.class.resolve_pub_link(aoc_auth_p,opt)
141
185
 
142
- if aoc_rest_p.has_key?(:org_url)
143
- # Pub Link only: get org url from pub link
144
- opt[:url] = aoc_rest_p[:org_url]
145
- aoc_rest_p.delete(:org_url)
146
- else
147
- # else url is mandatory
148
- raise ArgumentError,"Missing mandatory option: url" if opt[:url].nil?
149
- end
186
+ # test here because link may set url
187
+ raise ArgumentError,'Missing mandatory option: url' if opt[:url].nil?
150
188
 
151
189
  # get org name and domain from url
152
190
  organization,instance_domain=self.class.parse_url(opt[:url])
@@ -158,107 +196,98 @@ module Aspera
158
196
  aoc_auth_p[:base_url] = "#{api_url_base}/#{OAUTH_API_SUBPATH}/#{organization}"
159
197
  aoc_auth_p[:client_id]=opt[:client_id]
160
198
  aoc_auth_p[:client_secret] = opt[:client_secret]
199
+ aoc_auth_p[:scope] = opt[:scope]
161
200
 
162
- if !aoc_auth_p.has_key?(:grant)
163
- raise ArgumentError,"Missing mandatory option: auth" if opt[:auth].nil?
164
- aoc_auth_p[:grant] = opt[:auth]
201
+ # filled if pub link
202
+ if !aoc_auth_p.has_key?(:crtype)
203
+ raise ArgumentError,'Missing mandatory option: auth' if opt[:auth].nil?
204
+ aoc_auth_p[:crtype] = opt[:auth]
165
205
  end
166
206
 
167
207
  if aoc_auth_p[:client_id].nil?
168
208
  aoc_auth_p[:client_id],aoc_auth_p[:client_secret] = self.class.get_client_info()
169
209
  end
170
210
 
171
- raise ArgumentError,"Missing mandatory option: scope" if opt[:scope].nil?
172
- aoc_auth_p[:scope] = opt[:scope]
173
-
174
211
  # fill other auth parameters based on Oauth method
175
- case aoc_auth_p[:grant]
212
+ case aoc_auth_p[:crtype]
176
213
  when :web
177
- raise ArgumentError,"Missing mandatory option: redirect_uri" if opt[:redirect_uri].nil?
178
- aoc_auth_p[:redirect_uri] = opt[:redirect_uri]
214
+ raise ArgumentError,'Missing mandatory option: redirect_uri' if opt[:redirect_uri].nil?
215
+ aoc_auth_p[:web]={redirect_uri: opt[:redirect_uri]}
179
216
  when :jwt
217
+ raise ArgumentError,'Missing mandatory option: private_key' if opt[:private_key].nil?
218
+ raise ArgumentError,'Missing mandatory option: username' if opt[:username].nil?
219
+ aoc_auth_p[:jwt]={
220
+ private_key_obj: OpenSSL::PKey::RSA.new(opt[:private_key]),
221
+ payload: {
222
+ iss: aoc_auth_p[:client_id], # issuer
223
+ sub: opt[:username], # subject
224
+ aud: JWT_AUDIENCE
225
+ }
226
+ }
180
227
  # add jwt payload for global ids
181
- if CLIENT_APPS.include?(aoc_auth_p[:client_id])
182
- aoc_auth_p.merge!({:jwt_add=>{org: organization}})
183
- end
184
- raise ArgumentError,"Missing mandatory option: private_key" if opt[:private_key].nil?
185
- raise ArgumentError,"Missing mandatory option: username" if opt[:username].nil?
186
- private_key_PEM_string=opt[:private_key]
187
- aoc_auth_p[:jwt_audience] = JWT_AUDIENCE
188
- aoc_auth_p[:jwt_subject] = opt[:username]
189
- aoc_auth_p[:jwt_private_key_obj] = OpenSSL::PKey::RSA.new(private_key_PEM_string)
190
- when :url_token
191
- aoc_auth_p[:password]=opt[:password] unless opt[:password].nil?
192
- # nothing more
193
- else raise "ERROR: unsupported auth method: #{aoc_auth_p[:grant]}"
228
+ aoc_auth_p[:jwt][:payload][:org]=organization if GLOBAL_CLIENT_APPS.include?(aoc_auth_p[:client_id])
229
+ when :aoc_pub_link
230
+ # basic auth required for /token
231
+ aoc_auth_p[:auth]={type: :basic, username: aoc_auth_p[:client_id],password: aoc_auth_p[:client_secret]}
232
+ else raise "ERROR: unsupported auth method: #{aoc_auth_p[:crtype]}"
194
233
  end
195
234
  super(aoc_rest_p)
196
235
  end
197
236
 
198
- def key_chain=(keychain)
199
- raise "keychain already set" unless @key_chain.nil?
200
- @key_chain=keychain
201
- nil
237
+ def url_token_data
238
+ return nil unless params[:auth][:crtype].eql?(:aoc_pub_link)
239
+ # TODO: can there be several in list ?
240
+ return read('url_tokens')[:data].first
202
241
  end
203
242
 
204
- # additional transfer spec (tags) for package information
205
- def self.package_tags(package_info,operation)
206
- return {'tags'=>{'aspera'=>{'files'=>{
207
- 'package_id' => package_info['id'],
208
- 'package_name' => package_info['name'],
209
- 'package_operation' => operation
210
- }}}}
243
+ def key_chain=(keychain)
244
+ raise 'keychain already set' unless @key_chain.nil?
245
+ raise 'keychain must have get_secret' unless keychain.respond_to?(:get_secret)
246
+ @key_chain=keychain
211
247
  end
212
248
 
213
- # add details to show in analytics
214
- def self.analytics_ts(app,direction,ws_id,ws_name)
215
- # translate transfer to operation
216
- operation=case direction
217
- when 'send'; 'upload'
218
- when 'receive'; 'download'
219
- else raise "ERROR: unexpected value: #{direction}"
249
+ # cached user information
250
+ def user_info
251
+ if @user_info.nil?
252
+ # get our user's default information
253
+ @user_info=
254
+ begin
255
+ read('self')[:data]
256
+ rescue StandardError => e
257
+ Log.log.debug("ignoring error: #{e}")
258
+ {}
259
+ end
260
+ USER_INFO_FIELDS_MIN.each{|f|@user_info[f]='unknown' if @user_info[f].nil?}
220
261
  end
221
-
222
- return {
223
- 'tags' => {
224
- 'aspera' => {
225
- 'usage_id' => "aspera.files.workspace.#{ws_id}", # activity tracking
226
- 'files' => {
227
- 'files_transfer_action' => "#{operation}_#{app.gsub(/s$/,'')}",
228
- 'workspace_name' => ws_name, # activity tracking
229
- 'workspace_id' => ws_id,
230
- }
231
- }
232
- }
233
- }
262
+ return @user_info
234
263
  end
235
264
 
236
265
  # build ts addon for IBM Aspera Console (cookie)
237
- def self.console_ts(app,user_name,user_email)
238
- elements=[app,user_name,user_email].map{|e|Base64.strict_encode64(e)}
239
- elements.unshift('aspera.aoc')
240
- #Log.dump('elem1'.bg_red,elements[1])
241
- return {
242
- 'cookie'=>elements.join(':')
243
- }
266
+ def console_ts(app)
267
+ # we are sure that fields are not nil
268
+ elements=[app,user_info['name'],user_info['email']].map{|e|Base64.strict_encode64(e)}
269
+ elements.unshift(COOKIE_PREFIX)
270
+ return {'cookie'=>elements.join(':')}
244
271
  end
245
272
 
246
273
  # build "transfer info", 2 elements array with:
247
274
  # - transfer spec for aspera on cloud, based on node information and file id
248
275
  # - source and token regeneration method
249
276
  def tr_spec(app,direction,node_file,ts_add)
250
- # prepare the rest end point is used to generate the bearer token
251
- token_generation_method=lambda {|do_refresh|self.oauth_token(scope: self.class.node_scope(node_file[:node_info]['access_key'],SCOPE_NODE_USER), refresh: do_refresh)}
277
+ # get node api
278
+ node_api=get_node_api(node_file[:node_info])
279
+ # this lambda returns the bearer token for node, if
280
+ token_generation_lambda=lambda{|do_refresh|node_api.oauth_token(force_refresh: do_refresh)}
252
281
  # prepare transfer specification
253
282
  # note xfer_id and xfer_retry are set by the transfer agent itself
254
283
  transfer_spec={
255
284
  'direction' => direction,
256
- 'token' => token_generation_method.call(false), # first time, use cache
285
+ 'token' => token_generation_lambda.call(false), # first time, use cache
257
286
  'tags' => {
258
287
  'aspera' => {
259
288
  'app' => app,
260
289
  'files' => {
261
- 'node_id' => node_file[:node_info]['id'],
290
+ 'node_id' => node_file[:node_info]['id']
262
291
  }, # files
263
292
  'node' => {
264
293
  'access_key' => node_file[:node_info]['access_key'],
@@ -269,36 +298,40 @@ module Aspera
269
298
  } # tags
270
299
  }
271
300
  # add remote host info
272
- if @@use_standard_ports
273
- transfer_spec.merge!(DEFAULT_TSPEC_INFO)
301
+ if self.class.use_standard_ports
302
+ # get default TCP/UDP ports and transfer user
303
+ transfer_spec.merge!(Fasp::TransferSpec::AK_TSPEC_BASE)
304
+ # by default: same address as node API
274
305
  transfer_spec['remote_host']=node_file[:node_info]['host']
306
+ # 30 it's necessarily https scheme: webui does not allow anything else
307
+ if node_file[:node_info]['transfer_url'].is_a?(String) && !node_file[:node_info]['transfer_url'].empty?
308
+ transfer_spec['remote_host']=URI.parse(node_file[:node_info]['transfer_url']).host
309
+ end
275
310
  else
276
311
  # retrieve values from API
277
- std_t_spec=get_node_api(node_file[:node_info],scope: SCOPE_NODE_USER).create('files/download_setup',{:transfer_requests => [ { :transfer_request => {:paths => [ {"source"=>'/'} ] } } ] } )[:data]['transfer_specs'].first['transfer_spec']
312
+ std_t_spec=node_api.create('files/download_setup',{transfer_requests: [{ transfer_request: {paths: [{'source'=>'/'}] } }] })[:data]['transfer_specs'].first['transfer_spec']
278
313
  ['remote_host','remote_user','ssh_port','fasp_port'].each {|i| transfer_spec[i]=std_t_spec[i]}
279
314
  end
280
315
  # add caller provided transfer spec
281
316
  transfer_spec.deep_merge!(ts_add)
282
317
  # additional information for transfer agent
283
318
  source_and_token_generator={
284
- :src => :node_gen4,
285
- :regenerate_token => token_generation_method
319
+ src: :node_gen4,
320
+ regenerate_token: token_generation_lambda
286
321
  }
287
322
  return transfer_spec,source_and_token_generator
288
323
  end
289
324
 
290
325
  # returns a node API for access key
326
+ # @param node_info [Hash] with 'url' and 'access_key'
291
327
  # @param scope e.g. SCOPE_NODE_USER
292
328
  # no scope: requires secret
293
329
  # if secret provided beforehand: use it
294
- def get_node_api(node_info,options={})
295
- raise "INTERNAL ERROR: method parameters: options must ne hash" unless options.is_a?(Hash)
296
- options.keys.each {|k| raise "INTERNAL ERROR: not valid option: #{k}" unless [:scope,:use_secret].include?(k)}
297
- # get optional secret unless :use_secret is false (default is true)
298
- ak_secret=@key_chain.get_secret(node_info['access_key'],false) if !options.has_key?(:use_secret) or options[:use_secret]
299
- if ak_secret.nil? and !options.has_key?(:scope)
300
- raise "There must be at least one of: 'secret' or 'scope' for access key #{node_info['access_key']}"
301
- end
330
+ def get_node_api(node_info, scope: SCOPE_NODE_USER, use_secret: true)
331
+ raise 'internal error' unless node_info.is_a?(Hash) && node_info.has_key?('url') && node_info.has_key?('access_key')
332
+ # get optional secret unless :use_secret is false
333
+ ak_secret=@key_chain.get_secret(url: node_info['url'], username: node_info['access_key'], mandatory: false) if use_secret && !@key_chain.nil?
334
+ raise "There must be at least one of: 'secret' or 'scope' for access key #{node_info['access_key']}" if ak_secret.nil? && scope.nil?
302
335
  node_rest_params={base_url: node_info['url']}
303
336
  # if secret is available
304
337
  if !ak_secret.nil?
@@ -310,8 +343,8 @@ module Aspera
310
343
  else
311
344
  # X-Aspera-AccessKey required for bearer token only
312
345
  node_rest_params[:headers]= {'X-Aspera-AccessKey'=>node_info['access_key']}
313
- node_rest_params[:auth]=self.params[:auth].clone
314
- node_rest_params[:auth][:scope]=self.class.node_scope(node_info['access_key'],options[:scope])
346
+ node_rest_params[:auth]=params[:auth].clone
347
+ node_rest_params[:auth][:scope]=self.class.node_scope(node_info['access_key'],scope)
315
348
  end
316
349
  return Node.new(node_rest_params)
317
350
  end
@@ -320,7 +353,7 @@ module Aspera
320
353
  # @return split values
321
354
  def check_get_node_file(node_file)
322
355
  raise "node_file must be Hash (got #{node_file.class})" unless node_file.is_a?(Hash)
323
- raise "node_file must have 2 keys: :file_id and :node_info" unless node_file.keys.sort.eql?([:file_id,:node_info])
356
+ raise 'node_file must have 2 keys: :file_id and :node_info' unless node_file.keys.sort.eql?([:file_id,:node_info])
324
357
  node_info=node_file[:node_info]
325
358
  file_id=node_file[:file_id]
326
359
  raise "node_info must be Hash (got #{node_info.class}: #{node_info})" unless node_info.is_a?(Hash)
@@ -336,28 +369,28 @@ module Aspera
336
369
  @find_state[:found].push(entry.merge({'path'=>path})) if @find_state[:test_block].call(entry)
337
370
  # process link
338
371
  if entry[:type].eql?('link')
339
- sub_node_info=self.read("nodes/#{entry['target_node_id']}")[:data]
372
+ sub_node_info=read("nodes/#{entry['target_node_id']}")[:data]
340
373
  sub_opt={method: process_find_files, top_file_id: entry['target_id'], top_file_path: path}
341
- get_node_api(sub_node_info,scope: SCOPE_NODE_USER).crawl(self,sub_opt)
374
+ get_node_api(sub_node_info).crawl(self,sub_opt)
342
375
  end
343
- rescue => e
376
+ rescue StandardError => e
344
377
  Log.log.error("#{path}: #{e.message}")
345
378
  end
346
379
  # process all folders
347
380
  return true
348
381
  end
349
382
 
350
- def find_files( top_node_file, test_block )
383
+ def find_files(top_node_file, test_block)
351
384
  top_node_info,top_file_id=check_get_node_file(top_node_file)
352
385
  Log.log.debug("find_files: node_info=#{top_node_info}, fileid=#{top_file_id}")
353
386
  @find_state={found: [], test_block: test_block}
354
- get_node_api(top_node_info,scope: SCOPE_NODE_USER).crawl(self,{method: :process_find_files, top_file_id: top_file_id})
387
+ get_node_api(top_node_info).crawl(self,{method: :process_find_files, top_file_id: top_file_id})
355
388
  result=@find_state[:found]
356
389
  @find_state=nil
357
390
  return result
358
391
  end
359
392
 
360
- def process_resolve_node_file(entry,path)
393
+ def process_resolve_node_file(entry,_path)
361
394
  # stop digging here if not in right path
362
395
  return false unless entry['name'].eql?(@resolve_state[:path].first)
363
396
  # ok it matches, so we remove the match
@@ -368,11 +401,11 @@ module Aspera
368
401
  raise "#{entry['name']} is a file, expecting folder to find: #{@resolve_state[:path]}" unless @resolve_state[:path].empty?
369
402
  @resolve_state[:result][:file_id]=entry['id']
370
403
  when 'link'
371
- @resolve_state[:result][:node_info]=self.read("nodes/#{entry['target_node_id']}")[:data]
404
+ @resolve_state[:result][:node_info]=read("nodes/#{entry['target_node_id']}")[:data]
372
405
  if @resolve_state[:path].empty?
373
406
  @resolve_state[:result][:file_id]=entry['target_id']
374
407
  else
375
- get_node_api(@resolve_state[:result][:node_info],scope: SCOPE_NODE_USER).crawl(self,{method: :process_resolve_node_file, top_file_id: entry['target_id']})
408
+ get_node_api(@resolve_state[:result][:node_info]).crawl(self,{method: :process_resolve_node_file, top_file_id: entry['target_id']})
376
409
  end
377
410
  when 'folder'
378
411
  if @resolve_state[:path].empty?
@@ -391,15 +424,15 @@ module Aspera
391
424
  # @param top_node_file Array [root node,file id]
392
425
  # @param element_path_string String path of element
393
426
  # supports links to secondary nodes
394
- def resolve_node_file( top_node_file, element_path_string )
427
+ def resolve_node_file(top_node_file, element_path_string)
395
428
  top_node_info,top_file_id=check_get_node_file(top_node_file)
396
- path_elements=element_path_string.split(PATH_SEPARATOR).select{|i| !i.empty?}
429
+ path_elements=element_path_string.split(PATH_SEPARATOR).reject(&:empty?)
397
430
  result={node_info: top_node_info, file_id: nil}
398
431
  if path_elements.empty?
399
432
  result[:file_id]=top_file_id
400
433
  else
401
434
  @resolve_state={path: path_elements, result: result}
402
- get_node_api(top_node_info,scope: SCOPE_NODE_USER).crawl(self,{method: :process_resolve_node_file, top_file_id: top_file_id})
435
+ get_node_api(top_node_info).crawl(self,{method: :process_resolve_node_file, top_file_id: top_file_id})
403
436
  not_found=@resolve_state[:path]
404
437
  @resolve_state=nil
405
438
  raise "entry not found: #{not_found}" if result[:file_id].nil?
@@ -407,5 +440,25 @@ module Aspera
407
440
  return result
408
441
  end
409
442
 
443
+ # @param entity_type path of entuty in API
444
+ # @param entity_name name of searched entity
445
+ # @param options additional search options
446
+ def lookup_entity_by_name(entity_type,entity_name,options={})
447
+ # returns entities whose name contains value (case insensitive)
448
+ matching_items=read(entity_type,options.merge({'q'=>entity_name}))[:data]
449
+ case matching_items.length
450
+ when 1 then return matching_items.first
451
+ when 0 then raise 'not found'
452
+ else
453
+ # multiple case insensitive partial matches, try case insensitive full match
454
+ # (anyway AoC does not allow creation of 2 entities with same case insensitive name)
455
+ icase_matches=matching_items.select{|i|i['name'].casecmp?(entity_name)}
456
+ case icase_matches.length
457
+ when 1 then return icase_matches.first
458
+ 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.)
459
+ else raise "Two entities cannot have the same case insensitive name: #{icase_matches.map{|i|i['name']}}"
460
+ end
461
+ end
462
+ end
410
463
  end # AoC
411
464
  end # Aspera