aspera-cli 4.24.1 → 4.24.2

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 (87) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +15 -2
  4. data/README.md +745 -436
  5. data/bin/ascli +20 -1
  6. data/bin/asession +23 -27
  7. data/lib/aspera/agent/base.rb +10 -21
  8. data/lib/aspera/agent/connect.rb +2 -3
  9. data/lib/aspera/agent/desktop.rb +2 -2
  10. data/lib/aspera/agent/direct.rb +49 -32
  11. data/lib/aspera/agent/factory.rb +31 -0
  12. data/lib/aspera/api/aoc.rb +79 -49
  13. data/lib/aspera/api/faspex.rb +212 -0
  14. data/lib/aspera/api/node.rb +99 -84
  15. data/lib/aspera/ascp/installation.rb +22 -21
  16. data/lib/aspera/ascp/management.rb +119 -23
  17. data/lib/aspera/assert.rb +14 -8
  18. data/lib/aspera/cli/extended_value.rb +15 -15
  19. data/lib/aspera/cli/formatter.rb +7 -5
  20. data/lib/aspera/cli/hints.rb +8 -0
  21. data/lib/aspera/cli/info.rb +4 -4
  22. data/lib/aspera/cli/main.rb +55 -70
  23. data/lib/aspera/cli/manager.rb +7 -4
  24. data/lib/aspera/cli/plugins/alee.rb +2 -1
  25. data/lib/aspera/cli/plugins/aoc.rb +110 -186
  26. data/lib/aspera/cli/plugins/ats.rb +4 -4
  27. data/lib/aspera/cli/plugins/base.rb +335 -0
  28. data/lib/aspera/cli/plugins/basic_auth.rb +45 -0
  29. data/lib/aspera/cli/plugins/config.rb +249 -220
  30. data/lib/aspera/cli/plugins/console.rb +15 -15
  31. data/lib/aspera/cli/plugins/cos.rb +2 -2
  32. data/lib/aspera/cli/plugins/factory.rb +78 -0
  33. data/lib/aspera/cli/plugins/faspex.rb +17 -20
  34. data/lib/aspera/cli/plugins/faspex5.rb +79 -193
  35. data/lib/aspera/cli/plugins/faspio.rb +14 -13
  36. data/lib/aspera/cli/plugins/httpgw.rb +13 -12
  37. data/lib/aspera/cli/plugins/node.rb +34 -32
  38. data/lib/aspera/cli/plugins/oauth.rb +48 -0
  39. data/lib/aspera/cli/plugins/orchestrator.rb +15 -13
  40. data/lib/aspera/cli/plugins/preview.rb +4 -4
  41. data/lib/aspera/cli/plugins/server.rb +15 -13
  42. data/lib/aspera/cli/plugins/shares.rb +18 -15
  43. data/lib/aspera/cli/sync_actions.rb +1 -1
  44. data/lib/aspera/cli/transfer_agent.rb +24 -20
  45. data/lib/aspera/cli/transfer_progress.rb +6 -6
  46. data/lib/aspera/cli/version.rb +3 -3
  47. data/lib/aspera/cli/wizard.rb +65 -53
  48. data/lib/aspera/colors.rb +6 -0
  49. data/lib/aspera/command_line_builder.rb +45 -50
  50. data/lib/aspera/command_line_converter.rb +2 -1
  51. data/lib/aspera/coverage.rb +1 -1
  52. data/lib/aspera/data_repository.rb +1 -1
  53. data/lib/aspera/environment.rb +10 -7
  54. data/lib/aspera/faspex_gw.rb +6 -4
  55. data/lib/aspera/faspex_postproc.rb +1 -1
  56. data/lib/aspera/keychain/macos_security.rb +1 -1
  57. data/lib/aspera/log.rb +37 -9
  58. data/lib/aspera/nagios.rb +1 -1
  59. data/lib/aspera/oauth/base.rb +17 -10
  60. data/lib/aspera/oauth/factory.rb +8 -8
  61. data/lib/aspera/oauth/web.rb +2 -2
  62. data/lib/aspera/products/connect.rb +4 -3
  63. data/lib/aspera/products/desktop.rb +1 -4
  64. data/lib/aspera/products/other.rb +9 -1
  65. data/lib/aspera/products/transferd.rb +0 -1
  66. data/lib/aspera/rest.rb +126 -83
  67. data/lib/aspera/ssh.rb +3 -3
  68. data/lib/aspera/sync/args.schema.yaml +46 -3
  69. data/lib/aspera/sync/conf.schema.yaml +130 -94
  70. data/lib/aspera/sync/operations.rb +16 -16
  71. data/lib/aspera/temp_file_manager.rb +17 -5
  72. data/lib/aspera/transfer/error.rb +16 -7
  73. data/lib/aspera/transfer/parameters.rb +34 -20
  74. data/lib/aspera/transfer/resumer.rb +74 -0
  75. data/lib/aspera/transfer/spec.rb +4 -3
  76. data/lib/aspera/transfer/spec.schema.yaml +132 -51
  77. data/lib/aspera/transfer/spec_doc.rb +41 -35
  78. data/lib/aspera/uri_reader.rb +1 -1
  79. data/lib/aspera/web_auth.rb +6 -6
  80. data.tar.gz.sig +0 -0
  81. metadata +9 -7
  82. metadata.gz.sig +0 -0
  83. data/lib/aspera/cli/basic_auth_plugin.rb +0 -43
  84. data/lib/aspera/cli/plugin.rb +0 -333
  85. data/lib/aspera/cli/plugin_factory.rb +0 -81
  86. data/lib/aspera/resumer.rb +0 -77
  87. data/lib/aspera/transfer/error_info.rb +0 -91
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/rest'
4
+ require 'aspera/oauth/base'
5
+ require 'digest'
6
+
7
+ module Aspera
8
+ # Implement OAuth for Faspex public link
9
+ class FaspexPubLink < OAuth::Base
10
+ class << self
11
+ attr_accessor :additional_info
12
+ end
13
+ # @param context The `context` query parameter in public link
14
+ # @param redirect_uri URI of web UI login
15
+ # @param path_authorize Path to provide passcode
16
+ def initialize(
17
+ context:,
18
+ redirect_uri:,
19
+ path_authorize: 'authorize_public_link',
20
+ **base_params
21
+ )
22
+ # a unique identifier could also be the passcode inside
23
+ super(**base_params, cache_ids: [Digest::SHA256.hexdigest(context)[0..23]])
24
+ @context = context
25
+ @redirect_uri = redirect_uri
26
+ @path_authorize = path_authorize
27
+ end
28
+
29
+ def create_token
30
+ # Exchange context (passcode) for code
31
+ resp = api.call(
32
+ operation: 'GET',
33
+ subpath: @path_authorize,
34
+ query: {
35
+ response_type: :code,
36
+ state: @context,
37
+ client_id: client_id,
38
+ redirect_uri: @redirect_uri
39
+ },
40
+ exception: false
41
+ )
42
+ # code / state located in redirected URL query
43
+ info = Rest.query_to_h(URI.parse(resp[:http]['Location']).query)
44
+ Log.dump(:info, info)
45
+ raise Error, info['action_message'] if info['action_message']
46
+ Aspera.assert(info['code']){'Missing code in answer'}
47
+ # Exchange code for token
48
+ return create_token_call(optional_scope_client_id.merge(
49
+ grant_type: 'authorization_code',
50
+ code: info['code'],
51
+ redirect_uri: @redirect_uri
52
+ ))
53
+ end
54
+ end
55
+ OAuth::Factory.instance.register_token_creator(FaspexPubLink)
56
+ module Api
57
+ class Faspex < Aspera::Rest
58
+ # endpoint for authentication API
59
+ PATH_AUTH = 'auth'
60
+ PATH_API_V5 = 'api/v5'
61
+ PATH_HEALTH = 'configuration/ping'
62
+ private_constant :PATH_API_V5,
63
+ :PATH_HEALTH,
64
+ :PATH_AUTH
65
+ RECIPIENT_TYPES = %w[user workgroup external_user distribution_list shared_inbox].freeze
66
+ PACKAGE_TERMINATED = %w[completed failed].freeze
67
+ # list of supported mailbox types (to list packages)
68
+ API_LIST_MAILBOX_TYPES = %w[inbox inbox_history inbox_all inbox_all_history outbox outbox_history pending pending_history all].freeze
69
+ # PACKAGE_SEND_FROM_REMOTE_SOURCE = 'remote_source'
70
+ # Faspex API v5: get transfer spec for connect
71
+ TRANSFER_CONNECT = 'connect'
72
+ ADMIN_RESOURCES = %i[
73
+ accounts distribution_lists contacts jobs workgroups shared_inboxes nodes oauth_clients registrations saml_configs
74
+ metadata_profiles email_notifications alternate_addresses webhooks
75
+ ].freeze
76
+ # states for jobs not in final state
77
+ JOB_RUNNING = %w[queued working].freeze
78
+ PATH_STANDARD_ROOT = '/aspera/faspex'
79
+ PATH_API_DETECT = "#{PATH_API_V5}/#{PATH_HEALTH}"
80
+ HEADER_ITERATION_TOKEN = 'X-Aspera-Next-Iteration-Token'
81
+ HEADER_FASPEX_VERSION = 'X-IBM-Aspera'
82
+ EMAIL_NOTIF_LIST = %w[
83
+ welcome_email
84
+ forgot_password
85
+ package_received
86
+ package_received_cc
87
+ package_sent_cc
88
+ package_downloaded
89
+ package_downloaded_cc
90
+ workgroup_package
91
+ upload_result
92
+ upload_result_cc
93
+ relay_started_cc
94
+ relay_finished_cc
95
+ relay_error_cc
96
+ shared_inbox_invitation
97
+ shared_inbox_submit
98
+ personal_invitation
99
+ personal_submit
100
+ account_approved
101
+ account_denied
102
+ package_file_processing_failed_sender
103
+ package_file_processing_failed_recipient
104
+ relay_failed_admin
105
+ relay_failed
106
+ admin_sync_failed
107
+ sync_failed
108
+ account_exist
109
+ mfa_code
110
+ ]
111
+ class << self
112
+ # @return true if the URL is a public link
113
+ def public_link?(url)
114
+ url.include?('?context=')
115
+ end
116
+ end
117
+ attr_reader :pub_link_context
118
+
119
+ def initialize(
120
+ url:,
121
+ auth:,
122
+ password: nil,
123
+ client_id: nil,
124
+ client_secret: nil,
125
+ redirect_uri: nil,
126
+ username: nil,
127
+ private_key: nil,
128
+ passphrase: nil
129
+ )
130
+ auth = :public_link if self.class.public_link?(url)
131
+ @pub_link_context = nil
132
+ super(**
133
+ case auth
134
+ when :public_link
135
+ # Get URL of final redirect of public link
136
+ redir_url = Rest.new(base_url: url, redirect_max: 3).call(operation: 'GET')[:http].uri.to_s
137
+ Log.dump(:redir_url, redir_url, level: :trace1)
138
+ # get context from query
139
+ encoded_context = Rest.query_to_h(URI.parse(redir_url).query)['context']
140
+ raise ParameterError, 'Bad faspex5 public link, missing context in query' if encoded_context.nil?
141
+ # public link information (contains passcode and allowed usage)
142
+ @pub_link_context = JSON.parse(Base64.decode64(encoded_context))
143
+ Log.dump(:pub_link_context, @pub_link_context, level: :trace1)
144
+ # Get the base url, i.e. .../aspera/faspex
145
+ base_url = redir_url.gsub(%r{/public/.*}, '').gsub(/\?.*/, '')
146
+ # Get web UI client_id and redirect_uri
147
+ # TODO: change this for something more reliable
148
+ config = JSON.parse(Rest.new(base_url: "#{base_url}/config.js", redirect_max: 3).call(operation: 'GET')[:data].sub(/^[^=]+=/, '').gsub(/([a-z_]+):/, '"\1":').delete("\n ").tr("'", '"')).symbolize_keys
149
+ Log.dump(:configjs, config)
150
+ {
151
+ base_url: "#{base_url}/#{PATH_API_V5}",
152
+ auth: {
153
+ type: :oauth2,
154
+ base_url: "#{base_url}/#{PATH_AUTH}",
155
+ grant_method: :faspex_pub_link,
156
+ context: encoded_context,
157
+ client_id: config[:client_id],
158
+ redirect_uri: config[:redirect_uri]
159
+ }
160
+ }
161
+ # old: headers: {'Passcode' => @pub_link_context['passcode']}
162
+ when :boot
163
+ Aspera.assert(password, type: ParameterError){'Missing password'}
164
+ # the password here is the token copied directly from browser in developer mode
165
+ {
166
+ base_url: "#{url}/#{PATH_API_V5}",
167
+ headers: {'Authorization' => password}
168
+ }
169
+ when :web
170
+ Aspera.assert(client_id, type: ParameterError){'Missing client_id'}
171
+ Aspera.assert(redirect_uri, type: ParameterError){'Missing redirect_uri'}
172
+ # opens a browser and ask user to auth using web
173
+ {
174
+ base_url: "#{url}/#{PATH_API_V5}",
175
+ auth: {
176
+ type: :oauth2,
177
+ base_url: "#{url}/#{PATH_AUTH}",
178
+ grant_method: :web,
179
+ client_id: client_id,
180
+ redirect_uri: redirect_uri
181
+ }
182
+ }
183
+ when :jwt
184
+ Aspera.assert(client_id, type: ParameterError){'Missing client_id'}
185
+ Aspera.assert(private_key, type: ParameterError){'Missing private_key'}
186
+ {
187
+ base_url: "#{url}/#{PATH_API_V5}",
188
+ auth: {
189
+ type: :oauth2,
190
+ base_url: "#{url}/#{PATH_AUTH}",
191
+ grant_method: :jwt,
192
+ client_id: client_id,
193
+ payload: {
194
+ iss: client_id, # issuer
195
+ aud: client_id, # audience (this field is not clear...)
196
+ sub: "user:#{username}" # subject is a user
197
+ },
198
+ private_key_obj: OpenSSL::PKey::RSA.new(private_key, passphrase),
199
+ headers: {typ: 'JWT'}
200
+ }
201
+ }
202
+ else Aspera.error_unexpected_value(auth, type: ParameterError){'auth'}
203
+ end
204
+ )
205
+ end
206
+
207
+ def auth_api
208
+ Rest.new(**params, base_url: base_url.sub(PATH_API_V5, PATH_AUTH))
209
+ end
210
+ end
211
+ end
212
+ end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'aspera/cli/error'
4
3
  require 'aspera/transfer/spec'
5
4
  require 'aspera/rest'
6
5
  require 'aspera/oauth'
@@ -17,14 +16,18 @@ module Aspera
17
16
  module Api
18
17
  # Provides additional functions using node API with gen4 extensions (access keys)
19
18
  class Node < Aspera::Rest
19
+ # Separator between node.AK and user:all
20
20
  SCOPE_SEPARATOR = ':'
21
21
  SCOPE_NODE_PREFIX = 'node.'
22
+ # Accepted types in `file_matcher`
22
23
  MATCH_TYPES = [String, Proc, Regexp, NilClass].freeze
24
+ # Delimiter in decoded node token
23
25
  SIGNATURE_DELIMITER = '==SIGNATURE=='
26
+ # Default validity when generating a bearer token "manually"
24
27
  BEARER_TOKEN_VALIDITY_DEFAULT = 86400
25
- # fields in @app_info
28
+ # Fields in @app_info
26
29
  REQUIRED_APP_INFO_FIELDS = %i[api app node_info workspace_id workspace_name].freeze
27
- # methods of @app_info[:api]
30
+ # Methods of @app_info[:api]
28
31
  REQUIRED_APP_API_METHODS = %i[node_api_from add_ts_tags].freeze
29
32
  private_constant :SCOPE_SEPARATOR, :SCOPE_NODE_PREFIX, :MATCH_TYPES,
30
33
  :SIGNATURE_DELIMITER, :BEARER_TOKEN_VALIDITY_DEFAULT,
@@ -32,30 +35,40 @@ module Aspera
32
35
 
33
36
  # Node API permissions
34
37
  ACCESS_LEVELS = %w[delete list mkdir preview read rename write].freeze
38
+ # Special HTTP Headers
35
39
  HEADER_X_ASPERA_ACCESS_KEY = 'X-Aspera-AccessKey'
36
40
  HEADER_X_TOTAL_COUNT = 'X-Total-Count'
37
41
  HEADER_X_CACHE_CONTROL = 'X-Aspera-Cache-Control'
38
42
  HEADER_X_NEXT_ITER_TOKEN = 'X-Aspera-Next-Iteration-Token'
43
+ # Node sub-scopes
39
44
  SCOPE_USER = 'user:all'
40
45
  SCOPE_ADMIN = 'admin:all'
41
46
  # / in cloud
42
47
  PATH_SEPARATOR = '/'
43
48
 
44
- # register node special token decoder
49
+ # Register node special token decoder
45
50
  OAuth::Factory.instance.register_decoder(lambda{ |token| Node.decode_bearer_token(token)})
46
51
 
47
- # class instance variable, access with accessors on class
52
+ # Class instance variable, access with accessors on class
48
53
  @use_standard_ports = true
49
54
  @use_node_cache = true
50
55
 
51
56
  class << self
52
- # set to false to read transfer parameters from download_setup
57
+ # Set to false to read transfer parameters from download_setup
53
58
  attr_accessor :use_standard_ports
54
- # set to false to bypass cache in redis
59
+ # Set to false to bypass cache in redis
55
60
  attr_accessor :use_node_cache
56
61
  attr_reader :use_dynamic_key
57
62
 
58
- # set private key to be used
63
+ # Adds cache control header, as globally specified to read request
64
+ # Use like this: read(...,**cache_control)
65
+ def cache_control
66
+ headers = {'Accept' => Rest::MIME_JSON}
67
+ headers[HEADER_X_CACHE_CONTROL] = 'no-cache' unless use_node_cache
68
+ {headers: headers}
69
+ end
70
+
71
+ # Set private key to be used
59
72
  # @param pem_content [String] PEM encoded private key
60
73
  def use_dynamic_key=(pem_content)
61
74
  Aspera.assert_type(pem_content, String)
@@ -67,7 +80,7 @@ module Aspera
67
80
  def add_public_key(h)
68
81
  if @dynamic_key
69
82
  ssh_key = Net::SSH::Buffer.from(:key, @dynamic_key)
70
- # get pub key in OpenSSH public key format (authorized_keys)
83
+ # Get pub key in OpenSSH public key format (authorized_keys)
71
84
  h['public_keys'] = [
72
85
  ssh_key.read_string,
73
86
  Base64.strict_encode64(ssh_key.to_s)
@@ -93,14 +106,16 @@ module Aspera
93
106
  when String
94
107
  return ->(f){File.fnmatch(match_expression, f['name'], File::FNM_DOTMATCH)}
95
108
  when NilClass then return ->(_){true}
96
- else Aspera.error_unexpected_value(match_expression.class.name, type: Cli::BadArgument)
109
+ else Aspera.error_unexpected_value(match_expression.class.name, type: ParameterError)
97
110
  end
98
111
  end
99
112
 
113
+ # @return [Proc] lambda from provided CLI options
100
114
  def file_matcher_from_argument(options)
101
115
  return file_matcher(options.get_next_argument('filter', validation: MATCH_TYPES, mandatory: false))
102
116
  end
103
117
 
118
+ # Split path into folder + filename
104
119
  # @return [Array] containing folder + inside folder/file
105
120
  def split_folder(path)
106
121
  folder = path.split(PATH_SEPARATOR)
@@ -108,11 +123,14 @@ module Aspera
108
123
  [folder.join(PATH_SEPARATOR), inside]
109
124
  end
110
125
 
111
- # node API scopes
126
+ # Node API scopes
127
+ # @return [String] node scope
112
128
  def token_scope(access_key, scope)
113
129
  return [SCOPE_NODE_PREFIX, access_key, SCOPE_SEPARATOR, scope].join('')
114
130
  end
115
131
 
132
+ # Decode node scope into access key and scope
133
+ # @return [Hash]
116
134
  def decode_scope(scope)
117
135
  items = scope.split(SCOPE_SEPARATOR, 2)
118
136
  Aspera.assert(items.length.eql?(2)){"invalid scope: #{scope}"}
@@ -130,7 +148,7 @@ module Aspera
130
148
  Aspera.assert_type(payload['user_id'], String)
131
149
  Aspera.assert(!payload['user_id'].empty?)
132
150
  Aspera.assert_type(private_key, OpenSSL::PKey::RSA)
133
- # manage convenience parameters
151
+ # Manage convenience parameters
134
152
  expiration_sec = payload['_validity'] || BEARER_TOKEN_VALIDITY_DEFAULT
135
153
  payload.delete('_validity')
136
154
  scope = payload['_scope'] || SCOPE_USER
@@ -152,8 +170,9 @@ module Aspera
152
170
  return JSON.parse(Zlib::Inflate.inflate(Base64.decode64(token)).partition(SIGNATURE_DELIMITER).first)
153
171
  end
154
172
 
173
+ # @return [Hash] Headers to call node API with access key and auth
155
174
  def bearer_headers(bearer_auth, access_key: nil)
156
- # if username is not provided, use the access key from the token
175
+ # If username is not provided, use the access key from the token
157
176
  if access_key.nil?
158
177
  access_key = Node.decode_scope(Node.decode_bearer_token(OAuth::Factory.bearer_token(bearer_auth))['scope'])[:access_key]
159
178
  Aspera.assert(!access_key.nil?)
@@ -167,17 +186,17 @@ module Aspera
167
186
 
168
187
  attr_reader :app_info
169
188
 
170
- # @param app_info [Hash,NilClass] Special processing for AoC
189
+ # @param app_info [Hash,NilClass] App information, typically AoC
171
190
  # @param add_tspec [Hash,NilClass] Additional transfer spec
172
191
  # @param base_url [String] Rest parameters
173
192
  # @param auth [String,NilClass] Rest parameters
174
193
  # @param headers [String,NilClass] Rest parameters
175
194
  def initialize(app_info: nil, add_tspec: nil, **rest_args)
176
- # init Rest
195
+ # Init Rest
177
196
  super(**rest_args)
178
197
  @dynamic_key = nil
179
198
  @app_info = app_info
180
- # this is added to transfer spec, for instance to add tags (COS)
199
+ # This is added to transfer spec, for instance to add tags (COS)
181
200
  @add_tspec = add_tspec
182
201
  @std_t_spec_cache = nil
183
202
  if !@app_info.nil?
@@ -190,25 +209,15 @@ module Aspera
190
209
  end
191
210
  end
192
211
 
193
- # Call node API, possibly adding cache control header, as globally specified
194
- def read_with_cache(subpath, query = nil)
195
- headers = {'Accept' => Rest::MIME_JSON}
196
- headers[HEADER_X_CACHE_CONTROL] = 'no-cache' unless self.class.use_node_cache
197
- return call(
198
- operation: 'GET',
199
- subpath: subpath,
200
- headers: headers,
201
- query: query
202
- )[:data]
203
- end
204
-
205
- # update transfer spec with special additional tags
212
+ # Update transfer spec with special additional tags
213
+ # @param tspec [Hash] Transfer spec to be modified
214
+ # @return [Hash] initial modified tspec
206
215
  def add_tspec_info(tspec)
207
216
  tspec.deep_merge!(@add_tspec) unless @add_tspec.nil?
208
217
  return tspec
209
218
  end
210
219
 
211
- # @returns [Node] a Node or nil
220
+ # @returns [Node] a Node Api object or nil if no App defined
212
221
  def node_id_to_node(node_id)
213
222
  if !@app_info.nil?
214
223
  return self if node_id.eql?(@app_info[:node_info]['id'])
@@ -226,7 +235,7 @@ module Aspera
226
235
  # @param entry [Hash] entry in folder
227
236
  # @return [Boolean] true if target information is available
228
237
  def entry_has_link_information(entry)
229
- # if target information is missing in folder, try to get it on entry
238
+ # If target information is missing in folder, try to get it on entry
230
239
  if entry['target_node_id'].nil? || entry['target_id'].nil?
231
240
  link_entry = read("files/#{entry['id']}")
232
241
  entry['target_node_id'] = link_entry['target_node_id']
@@ -238,27 +247,28 @@ module Aspera
238
247
  end
239
248
 
240
249
  # Recursively browse in a folder (with non-recursive method)
241
- # sub folders are processed if the processing method returns true
242
- # links are processed on the respective node
250
+ # Entries of folders are processed if the processing method returns true
251
+ # Links are processed on the respective node
243
252
  # @param method_sym [Symbol] processing method, arguments: entry, path, state
244
253
  # @param state [Object] state object sent to processing method
245
254
  # @param top_file_id [String] file id to start at (default = access key root file id)
246
255
  # @param top_file_path [String] path of top folder (default = /)
247
- def process_folder_tree(method_sym:, state:, top_file_id:, top_file_path: '/')
256
+ # @para query [Hash, nil] optional query for `read`
257
+ def process_folder_tree(method_sym:, state:, top_file_id:, top_file_path: '/', query: nil)
248
258
  Aspera.assert(!top_file_path.nil?){'top_file_path not set'}
249
259
  Log.log.debug{"process_folder_tree: node=#{@app_info ? @app_info[:node_info]['id'] : 'nil'}, file id=#{top_file_id}, path=#{top_file_path}"}
250
- # start at top folder
260
+ # Start at top folder
251
261
  folders_to_explore = [{id: top_file_id, path: top_file_path}]
252
262
  Log.dump(:folders_to_explore, folders_to_explore)
253
263
  until folders_to_explore.empty?
254
- # consume first in job list
264
+ # Consume first in job list
255
265
  current_item = folders_to_explore.shift
256
266
  Log.log.debug{"Exploring #{current_item[:path]}".bg_green}
257
- # get folder content
267
+ # Get folder content
258
268
  folder_contents =
259
269
  begin
260
270
  # TODO: use header
261
- read_with_cache("files/#{current_item[:id]}/files")
271
+ read("files/#{current_item[:id]}/files", query, **self.class.cache_control)
262
272
  rescue StandardError => e
263
273
  Log.log.warn{"#{current_item[:path]}: #{e.class} #{e.message}"}
264
274
  []
@@ -271,9 +281,9 @@ module Aspera
271
281
  end
272
282
  current_path = File.join(current_item[:path], entry['name'])
273
283
  Log.log.debug{"process_folder_tree: checking #{current_path}"}
274
- # call block, continue only if method returns true
284
+ # Call block, continue only if method returns true
275
285
  next unless send(method_sym, entry, current_path, state)
276
- # entry type is file, folder or link
286
+ # Entry type is file, folder or link
277
287
  case entry['type']
278
288
  when 'folder'
279
289
  folders_to_explore.push({id: entry['id'], path: current_path})
@@ -303,9 +313,9 @@ module Aspera
303
313
  process_last_link ||= path.end_with?(PATH_SEPARATOR)
304
314
  path_elements = path.split(PATH_SEPARATOR).reject(&:empty?)
305
315
  return {api: self, file_id: top_file_id} if path_elements.empty?
306
- resolve_state = {path: path_elements, result: nil, process_last_link: process_last_link}
316
+ resolve_state = {path: path_elements, consumed: [], result: nil, process_last_link: process_last_link}
307
317
  process_folder_tree(method_sym: :process_api_fid, state: resolve_state, top_file_id: top_file_id)
308
- raise "entry not found: #{resolve_state[:path]}" if resolve_state[:result].nil?
318
+ raise ParameterError, "Entry not found: #{resolve_state[:path].first} in /#{resolve_state[:consumed].join(PATH_SEPARATOR)}" if resolve_state[:result].nil?
309
319
  Log.log.debug{"resolve_api_fid: #{path} -> #{resolve_state[:result][:api].base_url} #{resolve_state[:result][:file_id]}"}
310
320
  return resolve_state[:result]
311
321
  end
@@ -338,14 +348,14 @@ module Aspera
338
348
  source_paths =
339
349
  case file_info['type']
340
350
  when 'file'
341
- # if the single source is a file, we need to split into folder path and filename
351
+ # If the single source is a file, we need to split into folder path and filename
342
352
  src_dir_elements = source_folder.split(Api::Node::PATH_SEPARATOR)
343
353
  filename = src_dir_elements.pop
344
354
  apifid = resolve_api_fid(top_file_id, src_dir_elements.join(Api::Node::PATH_SEPARATOR), true)
345
- # filename is the last one, source folder is what remains
355
+ # Filename is the last one, source folder is what remains
346
356
  [{'source' => filename}]
347
357
  when 'link', 'folder'
348
- # single source is 'folder' or 'link'
358
+ # Single source is 'folder' or 'link'
349
359
  # TODO: add this ? , 'destination'=>file_info['name']
350
360
  [{'source' => '.'}]
351
361
  else Aspera.error_unexpected_value(file_info['type']){'source type'}
@@ -354,6 +364,9 @@ module Aspera
354
364
  [apifid, source_paths]
355
365
  end
356
366
 
367
+ # Recursively find files matching lambda
368
+ # @param top_file_id [String] Search root
369
+ # @param test_lambda [Proc] Test function
357
370
  def find_files(top_file_id, test_lambda)
358
371
  Log.log.debug{"find_files: file id=#{top_file_id}"}
359
372
  find_state = {found: [], test_lambda: test_lambda}
@@ -361,25 +374,28 @@ module Aspera
361
374
  return find_state[:found]
362
375
  end
363
376
 
364
- def list_files(top_file_id)
377
+ # Recursively list all files and folders
378
+ def list_files(top_file_id, query: nil)
365
379
  find_state = {found: []}
366
- process_folder_tree(method_sym: :process_list_files, state: find_state, top_file_id: top_file_id)
380
+ process_folder_tree(method_sym: :process_list_files, state: find_state, top_file_id: top_file_id, query: query)
367
381
  return find_state[:found]
368
382
  end
369
383
 
384
+ # Generate a refreshed auth token
370
385
  def refreshed_transfer_token
371
386
  return oauth.authorization(refresh: true)
372
387
  end
373
388
 
374
- # @return part of transfer spec with transport parameters only
389
+ # Get generic part of transfer spec with transport parameters only
390
+ # @return [Hash] Base transfer spec
375
391
  def transport_params
376
392
  if @std_t_spec_cache.nil?
377
- # retrieve values from API (and keep a copy/cache)
393
+ # Retrieve values from API (and keep a copy/cache)
378
394
  full_spec = create(
379
395
  'files/download_setup',
380
396
  {transfer_requests: [{transfer_request: {paths: [{source: '/'}]}}]}
381
397
  )['transfer_specs'].first['transfer_spec']
382
- # set available fields
398
+ # Set available fields
383
399
  @std_t_spec_cache = Transfer::Spec::TRANSPORT_FIELDS.each_with_object({}) do |i, h|
384
400
  h[i] = full_spec[i] if full_spec.key?(i)
385
401
  end
@@ -388,9 +404,9 @@ module Aspera
388
404
  end
389
405
 
390
406
  # Create transfer spec for gen4
391
- # @param file_id destination or source folder (id)
392
- # @param direction one of Transfer::Spec::DIRECTION_SEND, Transfer::Spec::DIRECTION_RECEIVE
393
- # @param ts_merge additional transfer spec to merge
407
+ # @param file_id [String] Destination or source folder (id)
408
+ # @param direction [Symbol] One of Transfer::Spec::DIRECTION_SEND, Transfer::Spec::DIRECTION_RECEIVE
409
+ # @param ts_merge [Hash,nil] Additional transfer spec to merge
394
410
  def transfer_spec_gen4(file_id, direction, ts_merge = nil)
395
411
  ak_name = nil
396
412
  ak_token = nil
@@ -402,7 +418,7 @@ module Aspera
402
418
  when :oauth2
403
419
  ak_name = params[:headers][HEADER_X_ASPERA_ACCESS_KEY]
404
420
  # TODO: token_generation_lambda = lambda{|do_refresh|oauth.authorization(refresh: do_refresh)}
405
- # get bearer token, possibly use cache
421
+ # Get bearer token, possibly use cache
406
422
  ak_token = oauth.authorization
407
423
  when :none
408
424
  ak_name = params[:headers][HEADER_X_ASPERA_ACCESS_KEY]
@@ -417,92 +433,91 @@ module Aspera
417
433
  'node' => {
418
434
  'access_key' => ak_name,
419
435
  'file_id' => file_id
420
- } # node
421
- } # aspera
422
- } # tags
436
+ }
437
+ }
438
+ }
423
439
  }
424
- # add specials tags (cos)
440
+ # Add specials tags (cos)
425
441
  add_tspec_info(transfer_spec)
426
442
  transfer_spec.deep_merge!(ts_merge) unless ts_merge.nil?
427
- # add application specific tags (AoC)
428
- app_info[:api].add_ts_tags(transfer_spec: transfer_spec, app_info: app_info) unless app_info.nil?
429
- # add remote host info
443
+ # Add application specific tags (AoC)
444
+ @app_info[:api].add_ts_tags(transfer_spec: transfer_spec, app_info: @app_info) unless @app_info.nil?
445
+ # Add remote host info
430
446
  if self.class.use_standard_ports
431
- # get default TCP/UDP ports and transfer user
447
+ # Get default TCP/UDP ports and transfer user
432
448
  transfer_spec.merge!(Transfer::Spec::AK_TSPEC_BASE)
433
- # by default: same address as node API
449
+ # By default: same address as node API
434
450
  transfer_spec['remote_host'] = URI.parse(base_url).host
435
451
  # AoC allows specification of other url
436
452
  transfer_spec['remote_host'] = @app_info[:node_info]['transfer_url'] if !@app_info.nil? && !@app_info[:node_info]['transfer_url'].nil? && !@app_info[:node_info]['transfer_url'].empty?
437
453
  info = read('info')
438
- # get the transfer user from info on access key
454
+ # Get the transfer user from info on access key
439
455
  transfer_spec['remote_user'] = info['transfer_user'] if info['transfer_user']
440
- # get settings from name.value array to hash key.value
456
+ # Get settings from name.value array to hash key.value
441
457
  settings = info['settings']&.each_with_object({}){ |i, h| h[i['name']] = i['value']}
442
- # check WSS ports
458
+ # Check WSS ports
443
459
  Transfer::Spec::WSS_FIELDS.each do |i|
444
460
  transfer_spec[i] = settings[i] if settings.key?(i)
445
461
  end if settings.is_a?(Hash)
446
462
  else
447
463
  transfer_spec.merge!(transport_params)
448
464
  end
449
- Log.log.warn{"Expected transfer user: #{Transfer::Spec::ACCESS_KEY_TRANSFER_USER}, but have #{transfer_spec['remote_user']}"} \
450
- unless transfer_spec['remote_user'].eql?(Transfer::Spec::ACCESS_KEY_TRANSFER_USER)
465
+ Aspera.assert_values(transfer_spec['remote_user'], Transfer::Spec::ACCESS_KEY_TRANSFER_USER, type: :warn){'transfer user'}
451
466
  return transfer_spec
452
467
  end
453
468
 
454
469
  private
455
470
 
456
- # method called in loop for each entry for `resolve_api_fid`
471
+ # Method called in loop for each entry for `resolve_api_fid`
472
+ # @return `true` to continue digging, `false` to stop processing: set state[:result] if found
457
473
  def process_api_fid(entry, path, state)
458
- # stop digging here if not in right path
474
+ # Stop digging here if not in right path
459
475
  return false unless entry['name'].eql?(state[:path].first)
460
- # ok it matches, so we remove the match, and continue digging
461
- state[:path].shift
476
+ # Ok it matches, so we remove the match, and continue digging
477
+ state[:consumed].push(state[:path].shift)
462
478
  path_fully_consumed = state[:path].empty?
463
479
  case entry['type']
464
480
  when 'file'
465
- # file must be terminal
481
+ # File must be terminal
466
482
  raise "#{entry['name']} is a file, expecting folder to find: #{state[:path]}" unless path_fully_consumed
467
- # it's terminal, we found it
468
- Log.log.debug{"resolve_api_fid: found #{path} -> #{entry['id']}"}
483
+ # It's terminal, we found it
484
+ Log.log.debug{"process_api_fid: found #{path} -> #{entry['id']}"}
469
485
  state[:result] = {api: self, file_id: entry['id']}
470
486
  return false
471
487
  when 'folder'
472
488
  if path_fully_consumed
473
- # we found it
489
+ # We found it
474
490
  state[:result] = {api: self, file_id: entry['id']}
475
491
  return false
476
492
  end
477
493
  when 'link'
478
494
  if path_fully_consumed
479
495
  if state[:process_last_link]
480
- # we found it
496
+ # We found it
481
497
  other_node = nil
482
498
  other_node = node_id_to_node(entry['target_node_id']) if entry_has_link_information(entry)
483
499
  raise Error, 'Cannot resolve link' if other_node.nil?
484
500
  state[:result] = {api: other_node, file_id: entry['target_id']}
485
501
  else
486
- # we found it but we do not process the link
502
+ # We found it but we do not process the link
487
503
  state[:result] = {api: self, file_id: entry['id']}
488
504
  end
489
505
  return false
490
506
  end
491
- else
492
- Log.log.warn{"Unknown element type: #{entry['type']}"}
507
+ else Aspera.error_unexpected_value(entry['type'], type: :warn){'folder entry type'}
493
508
  end
494
- # continue to dig folder
509
+ # Continue to dig folder
495
510
  return true
496
511
  end
497
512
 
498
- # method called in loop for each entry for `find_files`
513
+ # Method called in loop for each entry for `find_files`
499
514
  def process_find_files(entry, path, state)
500
515
  state[:found].push(entry.merge({'path' => path})) if state[:test_lambda].call(entry)
501
- # test all files deeply
516
+ # Test all files deeply
502
517
  return true
503
518
  end
504
519
 
505
- # method called in loop for each entry for `list_files`
520
+ # Method called in loop for each entry for `list_files`
506
521
  def process_list_files(entry, path, state)
507
522
  state[:found].push(entry.merge({'path' => path}))
508
523
  return false