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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +15 -2
- data/README.md +745 -436
- data/bin/ascli +20 -1
- data/bin/asession +23 -27
- data/lib/aspera/agent/base.rb +10 -21
- data/lib/aspera/agent/connect.rb +2 -3
- data/lib/aspera/agent/desktop.rb +2 -2
- data/lib/aspera/agent/direct.rb +49 -32
- data/lib/aspera/agent/factory.rb +31 -0
- data/lib/aspera/api/aoc.rb +79 -49
- data/lib/aspera/api/faspex.rb +212 -0
- data/lib/aspera/api/node.rb +99 -84
- data/lib/aspera/ascp/installation.rb +22 -21
- data/lib/aspera/ascp/management.rb +119 -23
- data/lib/aspera/assert.rb +14 -8
- data/lib/aspera/cli/extended_value.rb +15 -15
- data/lib/aspera/cli/formatter.rb +7 -5
- data/lib/aspera/cli/hints.rb +8 -0
- data/lib/aspera/cli/info.rb +4 -4
- data/lib/aspera/cli/main.rb +55 -70
- data/lib/aspera/cli/manager.rb +7 -4
- data/lib/aspera/cli/plugins/alee.rb +2 -1
- data/lib/aspera/cli/plugins/aoc.rb +110 -186
- data/lib/aspera/cli/plugins/ats.rb +4 -4
- data/lib/aspera/cli/plugins/base.rb +335 -0
- data/lib/aspera/cli/plugins/basic_auth.rb +45 -0
- data/lib/aspera/cli/plugins/config.rb +249 -220
- data/lib/aspera/cli/plugins/console.rb +15 -15
- data/lib/aspera/cli/plugins/cos.rb +2 -2
- data/lib/aspera/cli/plugins/factory.rb +78 -0
- data/lib/aspera/cli/plugins/faspex.rb +17 -20
- data/lib/aspera/cli/plugins/faspex5.rb +79 -193
- data/lib/aspera/cli/plugins/faspio.rb +14 -13
- data/lib/aspera/cli/plugins/httpgw.rb +13 -12
- data/lib/aspera/cli/plugins/node.rb +34 -32
- data/lib/aspera/cli/plugins/oauth.rb +48 -0
- data/lib/aspera/cli/plugins/orchestrator.rb +15 -13
- data/lib/aspera/cli/plugins/preview.rb +4 -4
- data/lib/aspera/cli/plugins/server.rb +15 -13
- data/lib/aspera/cli/plugins/shares.rb +18 -15
- data/lib/aspera/cli/sync_actions.rb +1 -1
- data/lib/aspera/cli/transfer_agent.rb +24 -20
- data/lib/aspera/cli/transfer_progress.rb +6 -6
- data/lib/aspera/cli/version.rb +3 -3
- data/lib/aspera/cli/wizard.rb +65 -53
- data/lib/aspera/colors.rb +6 -0
- data/lib/aspera/command_line_builder.rb +45 -50
- data/lib/aspera/command_line_converter.rb +2 -1
- data/lib/aspera/coverage.rb +1 -1
- data/lib/aspera/data_repository.rb +1 -1
- data/lib/aspera/environment.rb +10 -7
- data/lib/aspera/faspex_gw.rb +6 -4
- data/lib/aspera/faspex_postproc.rb +1 -1
- data/lib/aspera/keychain/macos_security.rb +1 -1
- data/lib/aspera/log.rb +37 -9
- data/lib/aspera/nagios.rb +1 -1
- data/lib/aspera/oauth/base.rb +17 -10
- data/lib/aspera/oauth/factory.rb +8 -8
- data/lib/aspera/oauth/web.rb +2 -2
- data/lib/aspera/products/connect.rb +4 -3
- data/lib/aspera/products/desktop.rb +1 -4
- data/lib/aspera/products/other.rb +9 -1
- data/lib/aspera/products/transferd.rb +0 -1
- data/lib/aspera/rest.rb +126 -83
- data/lib/aspera/ssh.rb +3 -3
- data/lib/aspera/sync/args.schema.yaml +46 -3
- data/lib/aspera/sync/conf.schema.yaml +130 -94
- data/lib/aspera/sync/operations.rb +16 -16
- data/lib/aspera/temp_file_manager.rb +17 -5
- data/lib/aspera/transfer/error.rb +16 -7
- data/lib/aspera/transfer/parameters.rb +34 -20
- data/lib/aspera/transfer/resumer.rb +74 -0
- data/lib/aspera/transfer/spec.rb +4 -3
- data/lib/aspera/transfer/spec.schema.yaml +132 -51
- data/lib/aspera/transfer/spec_doc.rb +41 -35
- data/lib/aspera/uri_reader.rb +1 -1
- data/lib/aspera/web_auth.rb +6 -6
- data.tar.gz.sig +0 -0
- metadata +9 -7
- metadata.gz.sig +0 -0
- data/lib/aspera/cli/basic_auth_plugin.rb +0 -43
- data/lib/aspera/cli/plugin.rb +0 -333
- data/lib/aspera/cli/plugin_factory.rb +0 -81
- data/lib/aspera/resumer.rb +0 -77
- 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
|
data/lib/aspera/api/node.rb
CHANGED
|
@@ -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
|
-
#
|
|
28
|
+
# Fields in @app_info
|
|
26
29
|
REQUIRED_APP_INFO_FIELDS = %i[api app node_info workspace_id workspace_name].freeze
|
|
27
|
-
#
|
|
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
|
-
#
|
|
49
|
+
# Register node special token decoder
|
|
45
50
|
OAuth::Factory.instance.register_decoder(lambda{ |token| Node.decode_bearer_token(token)})
|
|
46
51
|
|
|
47
|
-
#
|
|
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
|
-
#
|
|
57
|
+
# Set to false to read transfer parameters from download_setup
|
|
53
58
|
attr_accessor :use_standard_ports
|
|
54
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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:
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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]
|
|
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
|
-
#
|
|
195
|
+
# Init Rest
|
|
177
196
|
super(**rest_args)
|
|
178
197
|
@dynamic_key = nil
|
|
179
198
|
@app_info = app_info
|
|
180
|
-
#
|
|
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
|
-
#
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
242
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
267
|
+
# Get folder content
|
|
258
268
|
folder_contents =
|
|
259
269
|
begin
|
|
260
270
|
# TODO: use header
|
|
261
|
-
|
|
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
|
-
#
|
|
284
|
+
# Call block, continue only if method returns true
|
|
275
285
|
next unless send(method_sym, entry, current_path, state)
|
|
276
|
-
#
|
|
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 "
|
|
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
|
-
#
|
|
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
|
-
#
|
|
355
|
+
# Filename is the last one, source folder is what remains
|
|
346
356
|
[{'source' => filename}]
|
|
347
357
|
when 'link', 'folder'
|
|
348
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
392
|
-
# @param direction
|
|
393
|
-
# @param ts_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
|
-
#
|
|
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
|
-
}
|
|
421
|
-
}
|
|
422
|
-
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
423
439
|
}
|
|
424
|
-
#
|
|
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
|
-
#
|
|
428
|
-
app_info[:api].add_ts_tags(transfer_spec: transfer_spec, app_info: app_info) unless app_info.nil?
|
|
429
|
-
#
|
|
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
|
-
#
|
|
447
|
+
# Get default TCP/UDP ports and transfer user
|
|
432
448
|
transfer_spec.merge!(Transfer::Spec::AK_TSPEC_BASE)
|
|
433
|
-
#
|
|
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
|
-
#
|
|
454
|
+
# Get the transfer user from info on access key
|
|
439
455
|
transfer_spec['remote_user'] = info['transfer_user'] if info['transfer_user']
|
|
440
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
474
|
+
# Stop digging here if not in right path
|
|
459
475
|
return false unless entry['name'].eql?(state[:path].first)
|
|
460
|
-
#
|
|
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
|
-
#
|
|
481
|
+
# File must be terminal
|
|
466
482
|
raise "#{entry['name']} is a file, expecting folder to find: #{state[:path]}" unless path_fully_consumed
|
|
467
|
-
#
|
|
468
|
-
Log.log.debug{"
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
509
|
+
# Continue to dig folder
|
|
495
510
|
return true
|
|
496
511
|
end
|
|
497
512
|
|
|
498
|
-
#
|
|
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
|
-
#
|
|
516
|
+
# Test all files deeply
|
|
502
517
|
return true
|
|
503
518
|
end
|
|
504
519
|
|
|
505
|
-
#
|
|
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
|