aspera-cli 4.13.0 → 4.14.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 (64) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +28 -5
  4. data/CONTRIBUTING.md +17 -1
  5. data/README.md +782 -401
  6. data/examples/dascli +1 -1
  7. data/examples/rubyc +24 -0
  8. data/lib/aspera/aoc.rb +21 -32
  9. data/lib/aspera/ascmd.rb +1 -0
  10. data/lib/aspera/cli/basic_auth_plugin.rb +6 -6
  11. data/lib/aspera/cli/formatter.rb +17 -25
  12. data/lib/aspera/cli/main.rb +21 -27
  13. data/lib/aspera/cli/manager.rb +128 -114
  14. data/lib/aspera/cli/plugin.rb +87 -38
  15. data/lib/aspera/cli/plugins/alee.rb +2 -2
  16. data/lib/aspera/cli/plugins/aoc.rb +216 -102
  17. data/lib/aspera/cli/plugins/ats.rb +16 -18
  18. data/lib/aspera/cli/plugins/bss.rb +3 -3
  19. data/lib/aspera/cli/plugins/config.rb +177 -367
  20. data/lib/aspera/cli/plugins/console.rb +4 -6
  21. data/lib/aspera/cli/plugins/cos.rb +12 -13
  22. data/lib/aspera/cli/plugins/faspex.rb +17 -18
  23. data/lib/aspera/cli/plugins/faspex5.rb +332 -216
  24. data/lib/aspera/cli/plugins/node.rb +171 -142
  25. data/lib/aspera/cli/plugins/orchestrator.rb +15 -18
  26. data/lib/aspera/cli/plugins/preview.rb +38 -60
  27. data/lib/aspera/cli/plugins/server.rb +22 -15
  28. data/lib/aspera/cli/plugins/shares.rb +24 -33
  29. data/lib/aspera/cli/plugins/sync.rb +3 -3
  30. data/lib/aspera/cli/transfer_agent.rb +29 -26
  31. data/lib/aspera/cli/version.rb +1 -1
  32. data/lib/aspera/colors.rb +9 -7
  33. data/lib/aspera/data/6 +0 -0
  34. data/lib/aspera/environment.rb +7 -3
  35. data/lib/aspera/fasp/agent_connect.rb +5 -0
  36. data/lib/aspera/fasp/agent_direct.rb +5 -5
  37. data/lib/aspera/fasp/agent_httpgw.rb +138 -60
  38. data/lib/aspera/fasp/agent_trsdk.rb +2 -0
  39. data/lib/aspera/fasp/error_info.rb +2 -0
  40. data/lib/aspera/fasp/installation.rb +18 -19
  41. data/lib/aspera/fasp/parameters.rb +18 -17
  42. data/lib/aspera/fasp/parameters.yaml +2 -1
  43. data/lib/aspera/fasp/resume_policy.rb +3 -3
  44. data/lib/aspera/fasp/transfer_spec.rb +6 -5
  45. data/lib/aspera/fasp/uri.rb +23 -21
  46. data/lib/aspera/faspex_postproc.rb +1 -1
  47. data/lib/aspera/hash_ext.rb +12 -2
  48. data/lib/aspera/keychain/macos_security.rb +13 -13
  49. data/lib/aspera/log.rb +1 -0
  50. data/lib/aspera/node.rb +62 -80
  51. data/lib/aspera/oauth.rb +1 -1
  52. data/lib/aspera/persistency_action_once.rb +1 -1
  53. data/lib/aspera/preview/terminal.rb +61 -15
  54. data/lib/aspera/preview/utils.rb +3 -3
  55. data/lib/aspera/proxy_auto_config.js +2 -2
  56. data/lib/aspera/rest.rb +37 -0
  57. data/lib/aspera/secret_hider.rb +6 -1
  58. data/lib/aspera/ssh.rb +1 -1
  59. data/lib/aspera/sync.rb +2 -0
  60. data.tar.gz.sig +0 -0
  61. metadata +3 -4
  62. metadata.gz.sig +0 -0
  63. data/docs/test_env.conf +0 -186
  64. data/lib/aspera/data/7 +0 -0
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # cspell:words httpport targetrate minrate bwcap createpath lockpolicy lockminrate
4
+
3
5
  require 'aspera/log'
4
6
  require 'aspera/command_line_builder'
5
7
 
@@ -7,8 +9,8 @@ module Aspera
7
9
  module Fasp
8
10
  # translates a "faspe:" URI (used in Faspex 4) into transfer spec hash
9
11
  class Uri
10
- def initialize(fasplink)
11
- @fasp_uri = URI.parse(fasplink.gsub(' ', '%20'))
12
+ def initialize(fasp_link)
13
+ @fasp_uri = URI.parse(fasp_link.gsub(' ', '%20'))
12
14
  # TODO: check scheme is faspe
13
15
  end
14
16
 
@@ -18,32 +20,32 @@ module Aspera
18
20
  result_ts['remote_user'] = @fasp_uri.user
19
21
  result_ts['ssh_port'] = @fasp_uri.port
20
22
  result_ts['paths'] = [{'source' => URI.decode_www_form_component(@fasp_uri.path)}]
21
- # faspex does not encode trailing base64 encoded tags, fix that
23
+ # faspex does not encode trailing base64 padding, fix that to be able to decode properly
22
24
  fixed_query = @fasp_uri.query.gsub(/(=+)$/){|x|'%3D' * x.length}
23
25
 
24
26
  URI.decode_www_form(fixed_query).each do |i|
25
27
  name = i[0]
26
28
  value = i[1]
27
29
  case name
28
- when 'cookie' then result_ts['cookie'] = value
29
- when 'token' then result_ts['token'] = value
30
- when 'sshfp' then result_ts['sshfp'] = value
31
- when 'policy' then result_ts['rate_policy'] = value
32
- when 'httpport' then result_ts['http_fallback_port'] = value.to_i
33
- when 'targetrate' then result_ts['target_rate_kbps'] = value.to_i
34
- when 'minrate' then result_ts['min_rate_kbps'] = value.to_i
35
- when 'port' then result_ts['fasp_port'] = value.to_i
36
- when 'bwcap' then result_ts['target_rate_cap_kbps'] = value.to_i
37
- when 'enc' then result_ts['cipher'] = value.gsub(/^aes/, 'aes-').gsub(/cfb$/, '-cfb').gsub(/gcm$/, '-gcm').gsub(/--/, '-')
38
- when 'tags64' then result_ts['tags'] = JSON.parse(Base64.strict_decode64(value))
39
- when 'createpath' then result_ts['create_dir'] = CommandLineBuilder.yes_to_true(value)
40
- when 'fallback' then result_ts['http_fallback'] = CommandLineBuilder.yes_to_true(value)
41
- when 'lockpolicy' then result_ts['lock_rate_policy'] = CommandLineBuilder.yes_to_true(value)
30
+ when 'cookie' then result_ts['cookie'] = value
31
+ when 'token' then result_ts['token'] = value
32
+ when 'sshfp' then result_ts['sshfp'] = value
33
+ when 'policy' then result_ts['rate_policy'] = value
34
+ when 'httpport' then result_ts['http_fallback_port'] = value.to_i
35
+ when 'targetrate' then result_ts['target_rate_kbps'] = value.to_i
36
+ when 'minrate' then result_ts['min_rate_kbps'] = value.to_i
37
+ when 'port' then result_ts['fasp_port'] = value.to_i
38
+ when 'bwcap' then result_ts['target_rate_cap_kbps'] = value.to_i
39
+ when 'enc' then result_ts['cipher'] = value.gsub(/^aes/, 'aes-').gsub(/cfb$/, '-cfb').gsub(/gcm$/, '-gcm').gsub(/--/, '-')
40
+ when 'tags64' then result_ts['tags'] = JSON.parse(Base64.strict_decode64(value))
41
+ when 'createpath' then result_ts['create_dir'] = CommandLineBuilder.yes_to_true(value)
42
+ when 'fallback' then result_ts['http_fallback'] = CommandLineBuilder.yes_to_true(value)
43
+ when 'lockpolicy' then result_ts['lock_rate_policy'] = CommandLineBuilder.yes_to_true(value)
42
44
  when 'lockminrate' then result_ts['lock_min_rate'] = CommandLineBuilder.yes_to_true(value)
43
- when 'auth' then Log.log.debug{"ignoring auth #{name}=#{value}"} # TODO: translate into transfer spec ? yes/no
44
- when 'v' then Log.log.debug{"ignoring v #{name}=#{value}"} # TODO: translate into transfer spec ? 2
45
- when 'protect' then Log.log.debug{"ignoring protect #{name}=#{value}"} # TODO: translate into transfer spec ?
46
- else Log.log.warn{"URI parameter ignored: #{name} = #{value}"}
45
+ when 'auth' then Log.log.debug{"ignoring #{name}=#{value}"} # Not used (yes/no)
46
+ when 'v' then Log.log.debug{"ignoring #{name}=#{value}"} # rubocop:disable Lint/DuplicateBranch Not used (2)
47
+ when 'protect' then Log.log.debug{"ignoring #{name}=#{value}"} # rubocop:disable Lint/DuplicateBranch TODO: what is this ?
48
+ else Log.log.warn{"URI parameter ignored: #{name} = #{value}"}
47
49
  end
48
50
  end
49
51
  return result_ts
@@ -13,7 +13,7 @@ module Aspera
13
13
  def initialize(server, parameters)
14
14
  raise 'parameters must be Hash' unless parameters.is_a?(Hash)
15
15
  @parameters = parameters.symbolize_keys
16
- Log.dump(:postproc_parameters, @parameters)
16
+ Log.dump(:post_proc_parameters, @parameters)
17
17
  raise "unexpected key in parameters config: only: #{ALLOWED_PARAMETERS.join(', ')}" if @parameters.keys.any?{|k|!ALLOWED_PARAMETERS.include?(k)}
18
18
  @parameters[:script_folder] ||= '.'
19
19
  @parameters[:fail_on_error] ||= false
@@ -2,11 +2,21 @@
2
2
 
3
3
  class ::Hash
4
4
  def deep_merge(second)
5
- merge(second){|_key, v1, v2|Hash === v1 && Hash === v2 ? v1.deep_merge(v2) : v2}
5
+ merge(second){|_key, v1, v2|v1.is_a?(Hash) && v2.is_a?(Hash) ? v1.deep_merge(v2) : v2}
6
6
  end
7
7
 
8
8
  def deep_merge!(second)
9
- merge!(second){|_key, v1, v2|Hash === v1 && Hash === v2 ? v1.deep_merge!(v2) : v2}
9
+ merge!(second){|_key, v1, v2|v1.is_a?(Hash) && v2.is_a?(Hash) ? v1.deep_merge!(v2) : v2}
10
+ end
11
+
12
+ def deep_do(memory=nil, &block)
13
+ each do |key, value|
14
+ if value.is_a?(Hash)
15
+ value.deep_do(memory, &block)
16
+ else
17
+ yield(self, key, value, memory)
18
+ end
19
+ end
10
20
  end
11
21
  end
12
22
 
@@ -3,11 +3,11 @@
3
3
  # https://github.com/fastlane-community/security
4
4
  require 'aspera/cli/info'
5
5
 
6
- # enhance the gem to support other keychains
6
+ # enhance the gem to support other key chains
7
7
  module Aspera
8
8
  module Keychain
9
9
  module MacosSecurity
10
- # keychain based on macOS keychain, using `security` cmmand line
10
+ # keychain based on macOS keychain, using `security` command line
11
11
  class Keychain
12
12
  DOMAINS = %i[user system common dynamic].freeze
13
13
  LIST_OPTIONS = {
@@ -32,12 +32,12 @@ module Aspera
32
32
  getpass: :g
33
33
  }.freeze
34
34
  class << self
35
- def execute(command, options=nil, supported=nil, lastopt=nil)
35
+ def execute(command, options=nil, supported=nil, last_opt=nil)
36
36
  url = options&.delete(:url)
37
37
  if !url.nil?
38
38
  uri = URI.parse(url)
39
39
  raise 'only https' unless uri.scheme.eql?('https')
40
- options[:protocol] = 'htps'
40
+ options[:protocol] = 'htps' # cspell: disable-line
41
41
  raise 'host required in URL' if uri.host.nil?
42
42
  options[:server] = uri.host
43
43
  options[:path] = uri.path unless ['', '/'].include?(uri.path)
@@ -50,28 +50,28 @@ module Aspera
50
50
  cmd.push("-#{supported[k]}")
51
51
  cmd.push(v.shellescape) unless v.empty?
52
52
  end
53
- cmd.push(lastopt) unless lastopt.nil?
53
+ cmd.push(last_opt) unless last_opt.nil?
54
54
  Log.log.debug{"executing>>#{cmd.join(' ')}"}
55
55
  result = %x(#{cmd.join(' ')} 2>&1)
56
56
  Log.log.debug{"result>>[#{result}]"}
57
57
  return result
58
58
  end
59
59
 
60
- def keychains(output)
60
+ def key_chains(output)
61
61
  output.split("\n").collect { |line| new(line.strip.gsub(/^"|"$/, '')) }
62
62
  end
63
63
 
64
64
  def default
65
- keychains(execute('default-keychain')).first
65
+ key_chains(execute('default-keychain')).first
66
66
  end
67
67
 
68
68
  def login
69
- keychains(execute('login-keychain')).first
69
+ key_chains(execute('login-keychain')).first
70
70
  end
71
71
 
72
72
  def list(options={})
73
73
  raise ArgumentError, "Invalid domain #{options[:domain]}, expected one of: #{DOMAINS}" unless options[:domain].nil? || DOMAINS.include?(options[:domain])
74
- keychains(execute('list-keychains', options, LIST_OPTIONS))
74
+ key_chains(execute('list-key_chains', options, LIST_OPTIONS))
75
75
  end
76
76
 
77
77
  def by_name(name)
@@ -88,14 +88,14 @@ module Aspera
88
88
  [string].pack('H*').force_encoding('UTF-8')
89
89
  end
90
90
 
91
- def password(operation, passtype, options)
91
+ def password(operation, pass_type, options)
92
92
  raise "wrong operation: #{operation}" unless %i[add find delete].include?(operation)
93
- raise "wrong passtype: #{passtype}" unless %i[generic internet].include?(passtype)
93
+ raise "wrong pass_type: #{pass_type}" unless %i[generic internet].include?(pass_type)
94
94
  raise 'options shall be Hash' unless options.is_a?(Hash)
95
95
  missing = (operation.eql?(:add) ? %i[account service password] : %i[label]) - options.keys
96
96
  raise "missing options: #{missing}" unless missing.empty?
97
97
  options[:getpass] = '' if operation.eql?(:find)
98
- output = self.class.execute("#{operation}-#{passtype}-password", options, ADD_PASS_OPTIONS, @path)
98
+ output = self.class.execute("#{operation}-#{pass_type}-password", options, ADD_PASS_OPTIONS, @path)
99
99
  raise output.gsub(/^.*: /, '') if output.start_with?('security: ')
100
100
  return nil unless operation.eql?(:find)
101
101
  attributes = {}
@@ -143,7 +143,7 @@ module Aspera
143
143
  raise 'not found' if info.nil?
144
144
  result = options.clone
145
145
  result[:secret] = info['password']
146
- result[:description] = info['icmt']
146
+ result[:description] = info['icmt'] # cspell: disable-line
147
147
  return result
148
148
  end
149
149
 
data/lib/aspera/log.rb CHANGED
@@ -39,6 +39,7 @@ module Aspera
39
39
  end
40
40
  end
41
41
 
42
+ # Capture the output of $stderr and log it at debug level
42
43
  def capture_stderr
43
44
  real_stderr = $stderr
44
45
  $stderr = StringIO.new
data/lib/aspera/node.rb CHANGED
@@ -17,6 +17,7 @@ module Aspera
17
17
  MATCH_EXEC_PREFIX = 'exec:'
18
18
  HEADER_X_ASPERA_ACCESS_KEY = 'X-Aspera-AccessKey'
19
19
  PATH_SEPARATOR = '/'
20
+ TS_FIELDS_TO_COPY = %w[remote_host remote_user ssh_port fasp_port wss_enabled wss_port].freeze
20
21
 
21
22
  # register node special token decoder
22
23
  Oauth.register_decoder(lambda{|token|JSON.parse(Zlib::Inflate.inflate(Base64.decode64(token)).partition('==SIGNATURE==').first)})
@@ -84,15 +85,16 @@ module Aspera
84
85
  return nil
85
86
  end
86
87
 
87
- # recursively browse in a folder (with non-recursive method)
88
+ # Recursively browse in a folder (with non-recursive method)
88
89
  # sub folders are processed if the processing method returns true
89
90
  # @param state [Object] state object sent to processing method
90
- # @param method [Symbol] processing method name
91
91
  # @param top_file_id [String] file id to start at (default = access key root file id)
92
92
  # @param top_file_path [String] path of top folder (default = /)
93
- def process_folder_tree(state:, method:, top_file_id:, top_file_path: '/')
93
+ # @param block [Proc] processing method, args: entry, path, state
94
+ def process_folder_tree(state:, top_file_id:, top_file_path: '/', &block)
94
95
  raise 'INTERNAL ERROR: top_file_path not set' if top_file_path.nil?
95
- raise "INTERNAL ERROR: Missing method #{method}" unless respond_to?(method)
96
+ raise 'INTERNAL ERROR: Missing block' unless block
97
+ # start at top folder
96
98
  folders_to_explore = [{id: top_file_id, path: top_file_path}]
97
99
  Log.dump(:folders_to_explore, folders_to_explore)
98
100
  until folders_to_explore.empty?
@@ -109,9 +111,9 @@ module Aspera
109
111
  Log.dump(:folder_contents, folder_contents)
110
112
  folder_contents.each do |entry|
111
113
  relative_path = File.join(current_item[:path], entry['name'])
112
- Log.log.debug{"looking #{relative_path}".bg_green}
114
+ Log.log.debug{"process_folder_tree checking #{relative_path}"}
113
115
  # continue only if method returns true
114
- next unless send(method, entry, relative_path, state)
116
+ next unless yield(entry, relative_path, state)
115
117
  # entry type is file, folder or link
116
118
  case entry['type']
117
119
  when 'folder'
@@ -119,85 +121,74 @@ module Aspera
119
121
  when 'link'
120
122
  node_id_to_node(entry['target_node_id'])&.process_folder_tree(
121
123
  state: state,
122
- method: method,
123
124
  top_file_id: entry['target_id'],
124
- top_file_path: relative_path)
125
+ top_file_path: relative_path,
126
+ &block)
125
127
  end
126
128
  end
127
129
  end
128
130
  end # process_folder_tree
129
131
 
130
- # processing method to resolve a file path to id
131
- # @returns true if processing need to continue
132
- def process_resolve_node_path(entry, _path, state)
133
- # stop digging here if not in right path
134
- return false unless entry['name'].eql?(state[:path].first)
135
- # ok it matches, so we remove the match
136
- state[:path].shift
137
- case entry['type']
138
- when 'file'
139
- # file must be terminal
140
- raise "#{entry['name']} is a file, expecting folder to find: #{state[:path]}" unless state[:path].empty?
141
- # it's terminal, we found it
142
- state[:result] = {api: self, file_id: entry['id']}
143
- return false
144
- when 'folder'
145
- if state[:path].empty?
146
- # we found it
147
- state[:result] = {api: self, file_id: entry['id']}
148
- return false
149
- end
150
- when 'link'
151
- if state[:path].empty?
152
- # we found it
153
- other_node = node_id_to_node(entry['target_node_id'])
154
- raise 'cannot resolve link' if other_node.nil?
155
- state[:result] = {api: other_node, file_id: entry['target_id']}
156
- return false
157
- end
158
- else
159
- Log.log.warn{"Unknown element type: #{entry['type']}"}
160
- end
161
- # continue to dig folder
162
- return true
163
- end
164
-
165
132
  # Navigate the path from given file id
166
133
  # @param top_file_id [String] id initial file id
167
134
  # @param path [String] file path
168
135
  # @return [Hash] {.api,.file_id}
169
136
  def resolve_api_fid(top_file_id, path)
170
137
  raise 'file id shall be String' unless top_file_id.is_a?(String)
138
+ process_last_link = path.end_with?(PATH_SEPARATOR)
171
139
  path_elements = path.split(PATH_SEPARATOR).reject(&:empty?)
172
140
  return {api: self, file_id: top_file_id} if path_elements.empty?
173
141
  resolve_state = {path: path_elements, result: nil}
174
- process_folder_tree(state: resolve_state, method: :process_resolve_node_path, top_file_id: top_file_id)
175
- raise "entry not found: #{resolve_state[:path]}" if resolve_state[:result].nil?
176
- return resolve_state[:result]
177
- end
178
-
179
- # add entry to list if test block is success
180
- # @return [TrueClass,FalseClass]
181
- def process_find_files(entry, path, state)
182
- begin
183
- # add to result if match filter
184
- state[:found].push(entry.merge({'path' => path})) if state[:test_block].call(entry)
185
- # process link
186
- if entry[:type].eql?('link')
187
- other_node = node_id_to_node(entry['target_node_id'])
188
- other_node.process_folder_tree(state: state, method: process_find_files, top_file_id: entry['target_id'], top_file_path: path)
142
+ process_folder_tree(state: resolve_state, top_file_id: top_file_id) do |entry, _path, state|
143
+ # this block is called recursively for each entry in folder
144
+ # stop digging here if not in right path
145
+ next false unless entry['name'].eql?(state[:path].first)
146
+ # ok it matches, so we remove the match
147
+ state[:path].shift
148
+ case entry['type']
149
+ when 'file'
150
+ # file must be terminal
151
+ raise "#{entry['name']} is a file, expecting folder to find: #{state[:path]}" unless state[:path].empty?
152
+ # it's terminal, we found it
153
+ state[:result] = {api: self, file_id: entry['id']}
154
+ next false
155
+ when 'folder'
156
+ if state[:path].empty?
157
+ # we found it
158
+ state[:result] = {api: self, file_id: entry['id']}
159
+ next false
160
+ end
161
+ when 'link'
162
+ if state[:path].empty?
163
+ if process_last_link
164
+ # we found it
165
+ other_node = node_id_to_node(entry['target_node_id'])
166
+ raise 'cannot resolve link' if other_node.nil?
167
+ state[:result] = {api: other_node, file_id: entry['target_id']}
168
+ else
169
+ # we found it but we do not process the link
170
+ state[:result] = {api: self, file_id: entry['id']}
171
+ end
172
+ next false
173
+ end
174
+ else
175
+ Log.log.warn{"Unknown element type: #{entry['type']}"}
189
176
  end
190
- rescue StandardError => e
191
- Log.log.error{"#{path}: #{e.message}"}
177
+ # continue to dig folder
178
+ next true
192
179
  end
193
- # process all folders
194
- return true
180
+ raise "entry not found: #{resolve_state[:path]}" if resolve_state[:result].nil?
181
+ return resolve_state[:result]
195
182
  end
196
183
 
197
184
  def find_files(top_file_id, test_block)
198
185
  Log.log.debug{"find_files: file id=#{top_file_id}"}
199
186
  find_state = {found: [], test_block: test_block}
200
- process_folder_tree(state: find_state, method: :process_find_files, top_file_id: top_file_id)
187
+ process_folder_tree(state: find_state, top_file_id: top_file_id) do |entry, path, state|
188
+ state[:found].push(entry.merge({'path' => path})) if state[:test_block].call(entry)
189
+ # test all files deeply
190
+ true
191
+ end
201
192
  return find_state[:found]
202
193
  end
203
194
 
@@ -212,6 +203,8 @@ module Aspera
212
203
  case params[:auth][:type]
213
204
  when :basic
214
205
  ak_name = params[:auth][:username]
206
+ raise 'ERROR: no secret in node object' unless params[:auth][:password]
207
+ ak_token = Rest.basic_creds(params[:auth][:username], params[:auth][:password])
215
208
  when :oauth2
216
209
  ak_name = params[:headers][HEADER_X_ASPERA_ACCESS_KEY]
217
210
  # TODO: token_generation_lambda = lambda{|do_refresh|oauth_token(force_refresh: do_refresh)}
@@ -235,12 +228,7 @@ module Aspera
235
228
  add_tspec_info(transfer_spec)
236
229
  transfer_spec.deep_merge!(ts_merge) unless ts_merge.nil?
237
230
  # add application specific tags (AoC)
238
- the_app = app_info
239
- the_app[:api].add_ts_tags(transfer_spec: transfer_spec, app_info: the_app) unless the_app.nil?
240
- # add basic token
241
- if transfer_spec['token'].nil?
242
- ts_basic_token(transfer_spec)
243
- end
231
+ app_info[:api].add_ts_tags(transfer_spec: transfer_spec, app_info: app_info) unless app_info.nil?
244
232
  # add remote host info
245
233
  if self.class.use_standard_ports
246
234
  # get default TCP/UDP ports and transfer user
@@ -251,23 +239,17 @@ module Aspera
251
239
  transfer_spec['remote_host'] = @app_info[:node_info]['transfer_url']
252
240
  end
253
241
  else
254
- # retrieve values from API
255
- std_t_spec = create(
242
+ # retrieve values from API (and keep a copy/cache)
243
+ @std_t_spec_cache ||= create(
256
244
  'files/download_setup',
257
245
  {transfer_requests: [{ transfer_request: {paths: [{'source' => '/'}] } }] }
258
246
  )[:data]['transfer_specs'].first['transfer_spec']
259
247
  # copy some parts
260
- %w[remote_host remote_user ssh_port fasp_port wss_enabled wss_port].each {|i| transfer_spec[i] = std_t_spec[i] if std_t_spec.key?(i)}
248
+ TS_FIELDS_TO_COPY.each {|i| transfer_spec[i] = @std_t_spec_cache[i] if @std_t_spec_cache.key?(i)}
261
249
  end
250
+ Log.log.warn{"Expected transfer user: #{Fasp::TransferSpec::ACCESS_KEY_TRANSFER_USER}, but have #{transfer_spec['remote_user']}"} \
251
+ unless transfer_spec['remote_user'].eql?(Fasp::TransferSpec::ACCESS_KEY_TRANSFER_USER)
262
252
  return transfer_spec
263
253
  end
264
-
265
- # set basic token in transfer spec
266
- def ts_basic_token(ts)
267
- Log.log.warn{"Expected transfer user: #{Fasp::TransferSpec::ACCESS_KEY_TRANSFER_USER}, but have #{ts['remote_user']}"} \
268
- unless ts['remote_user'].eql?(Fasp::TransferSpec::ACCESS_KEY_TRANSFER_USER)
269
- raise 'ERROR: no secret in node object' unless params[:auth][:password]
270
- ts['token'] = Rest.basic_creds(params[:auth][:username], params[:auth][:password])
271
- end
272
254
  end
273
255
  end
data/lib/aspera/oauth.rb CHANGED
@@ -192,7 +192,7 @@ module Aspera
192
192
  # replace default values
193
193
  @generic_parameters = DEFAULT_CREATE_PARAMS.deep_merge(a_params)
194
194
  # legacy
195
- @generic_parameters[:grant_method] ||= @generic_parameters.delete(:crtype) if @generic_parameters.key?(:crtype)
195
+ @generic_parameters[:grant_method] ||= @generic_parameters.delete(:crtype) if @generic_parameters.key?(:crtype) # cspell: disable-line
196
196
  # check that type is known
197
197
  self.class.token_creator(@generic_parameters[:grant_method])
198
198
  # specific parameters for the creation type
@@ -8,7 +8,7 @@ module Aspera
8
8
  class PersistencyActionOnce
9
9
  # @param :manager Mandatory Database
10
10
  # @param :data Mandatory object to persist, must be same object from begin to end (assume array by default)
11
- # @param :id Mandatory identifiers
11
+ # @param :id Mandatory identifiers
12
12
  # @param :delete Optional delete persistency condition
13
13
  # @param :parse Optional parse method (default to JSON)
14
14
  # @param :format Optional dump method (default to JSON)
@@ -1,34 +1,80 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # cspell:words Magick MAGICKCORE ITERM mintty winsize termcap
4
+
3
5
  require 'rmagick' # https://rmagick.github.io/index.html
4
6
  require 'rainbow'
5
7
  require 'io/console'
6
8
  module Aspera
7
9
  module Preview
8
- # function conversion_type returns one of the types: CONVERSION_TYPES
10
+ # Generates a string that can display an image in a terminal
9
11
  class Terminal
12
+ # quantum depth is 8 or 16: convert xc: -format "%q" info:
13
+ # Rainbow only supports 8-bit colors
14
+ SHIFT_FOR_8_BIT = Magick::MAGICKCORE_QUANTUM_DEPTH - 8
15
+ ITERM_NAMES = %w[iTerm WezTerm mintty].freeze
16
+ TERM_ENV_VARS = %w[TERM_PROGRAM LC_TERMINAL].freeze
17
+ private_constant :SHIFT_FOR_8_BIT, :ITERM_NAMES, :TERM_ENV_VARS
10
18
  class << self
11
- def build(blob, reserved_lines: 0)
12
- # TODO: retrieve terminal ratio using
13
- font_ratio = 1.7
19
+ def build(blob, reserved_lines: 0, double_precision: true)
20
+ return iterm_display_image(blob) if iterm_supported?
21
+ image = Magick::ImageList.new.from_blob(blob)
14
22
  (term_rows, term_columns) = IO.console.winsize
15
23
  term_rows -= reserved_lines
16
- image = Magick::ImageList.new.from_blob(blob)
17
- chosen_factor = [term_rows / image.rows.to_f, term_columns / image.columns.to_f].min
18
- image = image.scale((image.columns * chosen_factor * font_ratio).to_i, (image.rows * chosen_factor).to_i)
19
- text_pixels = []
24
+ # compute scaling to fit terminal
25
+ fit_term_ratio = [term_rows / image.rows.to_f, term_columns / image.columns.to_f].min
26
+ # TODO: retrieve terminal font ratio using some termcap ?
27
+ font_ratio = 1.7
28
+ height_ratio = double_precision ? 2.0 : 1.0
29
+ image = image.scale((image.columns * fit_term_ratio * font_ratio).to_i, (image.rows * fit_term_ratio * height_ratio).to_i)
30
+ # get all pixel colors, adjusted for Rainbow
31
+ pixel_colors = []
20
32
  image.each_pixel do |pixel, col, row|
21
- text_pixels.push("\n") if col.eql?(0) && !row.eql?(0)
22
- pixel_rgb = [pixel.red, pixel.green, pixel.blue].map do |color|
23
- # quantum depth is 8 or 16: convert xc: -format "%q" info:
24
- # Rainbow only supports 8-bit colors
25
- color >> (Magick::MAGICKCORE_QUANTUM_DEPTH - 8)
33
+ pixel_rgb = [pixel.red, pixel.green, pixel.blue]
34
+ pixel_rgb = pixel_rgb.map { |color| color >> SHIFT_FOR_8_BIT } unless SHIFT_FOR_8_BIT.eql?(0)
35
+ # init 2-dim array
36
+ pixel_colors[row] ||= []
37
+ pixel_colors[row][col] = pixel_rgb
38
+ end
39
+ # now generate text
40
+ text_pixels = []
41
+ pixel_colors.each_with_index do |row_data, row|
42
+ next if double_precision && row.odd?
43
+ row_data.each_with_index do |pixel_rgb, col|
44
+ text_pixels.push("\n") if col.eql?(0) && !row.eql?(0)
45
+ if double_precision
46
+ text_pixels.push(Rainbow('▄').background(pixel_rgb).foreground(pixel_colors[row + 1][col]))
47
+ else
48
+ text_pixels.push(Rainbow(' ').background(pixel_rgb))
49
+ end
26
50
  end
27
- text_pixels.push(Rainbow(' ').background(pixel_rgb))
28
51
  end
29
52
  return text_pixels.join
30
53
  end
31
- end # class << self
54
+
55
+ # display image in iTerm2
56
+ def iterm_display_image(blob)
57
+ # image = Magick::ImageList.new.from_blob(blob)
58
+ arguments = {
59
+ inline: 1,
60
+ preserveAspectRatio: 1,
61
+ size: blob.length
62
+ # width: image.columns,
63
+ # height: image.rows
64
+ }.map { |k, v| "#{k}=#{v}" }.join(';')
65
+ # \a is BEL, \e is ESC : https://github.com/ruby/ruby/blob/master/doc/syntax/literals.rdoc#label-Strings
66
+ # https://iterm2.com/documentation-images.html
67
+ return "\e]1337;File=#{arguments}:#{Base64.encode64(blob)}\a"
68
+ end
69
+
70
+ # @return [Boolean] true if the terminal supports iTerm2 image display
71
+ def iterm_supported?
72
+ TERM_ENV_VARS.each do |env_var|
73
+ return true if ITERM_NAMES.any? { |term| ENV[env_var]&.include?(term) }
74
+ end
75
+ false
76
+ end
77
+ end # class << self
32
78
  end # class Terminal
33
79
  end # module Preview
34
80
  end # module Aspera
@@ -19,7 +19,7 @@ module Aspera
19
19
  private_constant :BASH_SPECIAL_CHARACTERS, :BASH_EXIT_NOT_FOUND, :EXTERNAL_TOOLS, :TEMP_FORMAT
20
20
 
21
21
  class << self
22
- # returns string with single quotes suitable for bash if there is any bash metacharacter
22
+ # returns string with single quotes suitable for bash if there is any bash meta-character
23
23
  def shell_quote(argument)
24
24
  return argument unless argument.chars.any?{|c|BASH_SPECIAL_CHARACTERS.include?(c)}
25
25
  return "'" + argument.gsub(/'/){|_s| "'\"'\"'"} + "'"
@@ -55,7 +55,7 @@ module Aspera
55
55
  raise "Error: #{command_symb} is not in the PATH"
56
56
  end
57
57
  unless exit_status.success?
58
- Log.log.error{"commandline: #{command}"}
58
+ Log.log.error{"command line: #{command}"}
59
59
  Log.log.error{"Error code: #{exit_status}"}
60
60
  Log.log.error{"stdout: #{stdout}"}
61
61
  Log.log.error{"stderr: #{stderr}"}
@@ -82,7 +82,7 @@ module Aspera
82
82
  result = external_command(:ffprobe, [
83
83
  '-loglevel', 'error',
84
84
  '-show_entries', 'format=duration',
85
- '-print_format', 'default=noprint_wrappers=1:nokey=1',
85
+ '-print_format', 'default=noprint_wrappers=1:nokey=1', # cspell:disable-line
86
86
  input_file])
87
87
  return result[:stdout].to_f
88
88
  end
@@ -70,7 +70,7 @@ function shExpMatch(str, shell_expr) {
70
70
  }
71
71
  function weekdayRange(wd1, wd2, gmt) {
72
72
  var today = new Date();
73
- var days = 'SUNMONTUEWEDTHUFRISAT';
73
+ var days = 'SUNMONTUEWEDTHUFRISAT'; // cspell: disable-line
74
74
  wd1 = wd1.toUpperCase();
75
75
  if (wd2 == undefined)
76
76
  wd2 = wd1;
@@ -138,7 +138,7 @@ function dateRange() {
138
138
  else
139
139
  return false;
140
140
  } else if (typeof arg == 'string') {
141
- var months = 'JANFEBMARAPRMAYJUNJULAUGSEPOCTNOVDEC';
141
+ var months = 'JANFEBMARAPRMAYJUNJULAUGSEPOCTNOVDEC'; // cspell: disable-line
142
142
  arg = arg.toUpperCase();
143
143
  arg = months.indexOf(arg);
144
144
  if (arg == -1)
data/lib/aspera/rest.rb CHANGED
@@ -38,6 +38,11 @@ module Aspera
38
38
 
39
39
  ARRAY_PARAMS = '[]'
40
40
 
41
+ private_constant :ARRAY_PARAMS
42
+
43
+ # error message when entity not found
44
+ ENTITY_NOT_FOUND = 'No such'
45
+
41
46
  class << self
42
47
  # define accessors
43
48
  @@global.each_key do |p|
@@ -50,6 +55,12 @@ module Aspera
50
55
 
51
56
  def basic_creds(user, pass); return "Basic #{Base64.strict_encode64("#{user}:#{pass}")}"; end
52
57
 
58
+ # used to build a parameter list prefixed with "[]"
59
+ # @param values [Array] list of values
60
+ def array_params(values)
61
+ return [ARRAY_PARAMS].concat(values)
62
+ end
63
+
53
64
  # build URI from URL and parameters and check it is http or https
54
65
  def build_uri(url, params=nil)
55
66
  uri = URI.parse(url)
@@ -337,5 +348,31 @@ module Aspera
337
348
  def cancel(subpath)
338
349
  return call({operation: 'CANCEL', subpath: subpath, headers: {'Accept' => 'application/json'}})
339
350
  end
351
+
352
+ # Query by name and returns a single result, else it throws an exception (no or multiple results)
353
+ # @param subpath path of entity in API
354
+ # @param search_name name of searched entity
355
+ # @param options additional search options
356
+ def lookup_by_name(subpath, search_name, options={})
357
+ # returns entities whose name contains value (case insensitive)
358
+ matching_items = read(subpath, options.merge({'q' => CGI.escape(search_name)}))[:data]
359
+ # API style: {totalcount:, ...} cspell: disable-line
360
+ # TODO: not generic enough ? move somewhere ? inheritance ?
361
+ matching_items = matching_items[subpath] if matching_items.is_a?(Hash)
362
+ raise "Internal error: expecting array, have #{matching_items.class}" unless matching_items.is_a?(Array)
363
+ case matching_items.length
364
+ when 1 then return matching_items.first
365
+ when 0 then raise %Q{#{ENTITY_NOT_FOUND} #{subpath}: "#{search_name}"}
366
+ else
367
+ # multiple case insensitive partial matches, try case insensitive full match
368
+ # (anyway AoC does not allow creation of 2 entities with same case insensitive name)
369
+ name_matches = matching_items.select{|i|i['name'].casecmp?(search_name)}
370
+ case name_matches.length
371
+ when 1 then return name_matches.first
372
+ when 0 then raise %Q(#{subpath}: multiple case insensitive partial match for: "#{search_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
373
+ else raise "Two entities cannot have the same case insensitive name: #{name_matches.map{|i|i['name']}}"
374
+ end
375
+ end
376
+ end
340
377
  end
341
378
  end # module Aspera