aspera-cli 4.16.0 → 4.17.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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +50 -19
- data/CONTRIBUTING.md +3 -1
- data/README.md +965 -793
- data/bin/asession +29 -21
- data/lib/aspera/{fasp/agent_alpha.rb → agent/alpha.rb} +26 -25
- data/lib/aspera/{fasp/agent_base.rb → agent/base.rb} +15 -12
- data/lib/aspera/{fasp/agent_connect.rb → agent/connect.rb} +13 -11
- data/lib/aspera/{fasp/agent_direct.rb → agent/direct.rb} +49 -53
- data/lib/aspera/{fasp/agent_httpgw.rb → agent/httpgw.rb} +20 -19
- data/lib/aspera/{fasp/agent_node.rb → agent/node.rb} +20 -33
- data/lib/aspera/{fasp/agent_trsdk.rb → agent/trsdk.rb} +11 -11
- data/lib/aspera/api/aoc.rb +586 -0
- data/lib/aspera/api/ats.rb +46 -0
- data/lib/aspera/api/cos_node.rb +95 -0
- data/lib/aspera/api/node.rb +344 -0
- data/lib/aspera/ascmd.rb +46 -10
- data/lib/aspera/{fasp → ascp}/installation.rb +5 -5
- data/lib/aspera/{fasp → ascp}/management.rb +3 -8
- data/lib/aspera/{fasp → ascp}/products.rb +1 -1
- data/lib/aspera/assert.rb +30 -30
- data/lib/aspera/cli/basic_auth_plugin.rb +11 -10
- data/lib/aspera/cli/extended_value.rb +1 -1
- data/lib/aspera/cli/formatter.rb +13 -13
- data/lib/aspera/cli/hints.rb +5 -5
- data/lib/aspera/cli/main.rb +35 -28
- data/lib/aspera/cli/manager.rb +25 -24
- data/lib/aspera/cli/plugin.rb +22 -15
- data/lib/aspera/cli/plugin_factory.rb +61 -0
- data/lib/aspera/cli/plugins/alee.rb +7 -7
- data/lib/aspera/cli/plugins/aoc.rb +83 -77
- data/lib/aspera/cli/plugins/ats.rb +32 -33
- data/lib/aspera/cli/plugins/bss.rb +3 -4
- data/lib/aspera/cli/plugins/config.rb +169 -186
- data/lib/aspera/cli/plugins/console.rb +8 -6
- data/lib/aspera/cli/plugins/cos.rb +19 -18
- data/lib/aspera/cli/plugins/faspex.rb +61 -54
- data/lib/aspera/cli/plugins/faspex5.rb +150 -103
- data/lib/aspera/cli/plugins/node.rb +68 -73
- data/lib/aspera/cli/plugins/orchestrator.rb +34 -44
- data/lib/aspera/cli/plugins/preview.rb +31 -31
- data/lib/aspera/cli/plugins/server.rb +31 -33
- data/lib/aspera/cli/plugins/shares.rb +13 -11
- data/lib/aspera/cli/sync_actions.rb +8 -8
- data/lib/aspera/cli/transfer_agent.rb +32 -19
- data/lib/aspera/cli/transfer_progress.rb +1 -1
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/colors.rb +5 -0
- data/lib/aspera/command_line_builder.rb +14 -14
- data/lib/aspera/coverage.rb +1 -2
- data/lib/aspera/data_repository.rb +1 -1
- data/lib/aspera/environment.rb +2 -3
- data/lib/aspera/faspex_gw.rb +5 -6
- data/lib/aspera/faspex_postproc.rb +1 -1
- data/lib/aspera/id_generator.rb +2 -2
- data/lib/aspera/json_rpc.rb +5 -5
- data/lib/aspera/keychain/encrypted_hash.rb +6 -6
- data/lib/aspera/keychain/macos_security.rb +27 -22
- data/lib/aspera/log.rb +2 -2
- data/lib/aspera/nagios.rb +3 -3
- data/lib/aspera/node_simulator.rb +5 -6
- data/lib/aspera/oauth/base.rb +143 -0
- data/lib/aspera/oauth/factory.rb +124 -0
- data/lib/aspera/oauth/generic.rb +34 -0
- data/lib/aspera/oauth/jwt.rb +51 -0
- data/lib/aspera/oauth/url_json.rb +31 -0
- data/lib/aspera/oauth/web.rb +50 -0
- data/lib/aspera/oauth.rb +5 -331
- data/lib/aspera/open_application.rb +7 -7
- data/lib/aspera/persistency_action_once.rb +4 -4
- data/lib/aspera/persistency_folder.rb +2 -2
- data/lib/aspera/preview/generator.rb +5 -5
- data/lib/aspera/preview/terminal.rb +3 -2
- data/lib/aspera/preview/utils.rb +3 -3
- data/lib/aspera/proxy_auto_config.rb +4 -4
- data/lib/aspera/rest.rb +175 -144
- data/lib/aspera/rest_errors_aspera.rb +3 -3
- data/lib/aspera/resumer.rb +77 -0
- data/lib/aspera/ssh.rb +6 -1
- data/lib/aspera/{fasp → transfer}/error.rb +3 -3
- data/lib/aspera/{fasp → transfer}/error_info.rb +1 -1
- data/lib/aspera/{fasp → transfer}/faux_file.rb +1 -1
- data/lib/aspera/{fasp → transfer}/parameters.rb +58 -89
- data/lib/aspera/{fasp/transfer_spec.rb → transfer/spec.rb} +18 -16
- data/lib/aspera/{fasp/parameters.yaml → transfer/spec.yaml} +4 -99
- data/lib/aspera/{fasp → transfer}/sync.rb +32 -32
- data/lib/aspera/{fasp → transfer}/uri.rb +9 -8
- data/lib/aspera/web_server_simple.rb +11 -3
- data.tar.gz.sig +0 -0
- metadata +36 -63
- metadata.gz.sig +0 -0
- data/lib/aspera/aoc.rb +0 -601
- data/lib/aspera/ats_api.rb +0 -47
- data/lib/aspera/cos_node.rb +0 -94
- data/lib/aspera/fasp/resume_policy.rb +0 -79
- data/lib/aspera/node.rb +0 -339
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/log'
|
|
4
|
+
require 'aspera/assert'
|
|
5
|
+
require 'aspera/rest'
|
|
6
|
+
require 'aspera/hash_ext'
|
|
7
|
+
require 'aspera/data_repository'
|
|
8
|
+
require 'aspera/transfer/spec'
|
|
9
|
+
require 'aspera/api/node'
|
|
10
|
+
require 'base64'
|
|
11
|
+
require 'cgi'
|
|
12
|
+
|
|
13
|
+
module Aspera
|
|
14
|
+
module Api
|
|
15
|
+
class AoC < Aspera::Rest
|
|
16
|
+
PRODUCT_NAME = 'Aspera on Cloud'
|
|
17
|
+
DEFAULT_WORKSPACE = ''
|
|
18
|
+
# Production domain of AoC
|
|
19
|
+
PROD_DOMAIN = 'ibmaspera.com' # cspell:disable-line
|
|
20
|
+
# to avoid infinite loop in pub link redirection
|
|
21
|
+
MAX_AOC_URL_REDIRECT = 10
|
|
22
|
+
CLIENT_ID_PREFIX = 'aspera.'
|
|
23
|
+
# Well-known AoC globals client apps
|
|
24
|
+
GLOBAL_CLIENT_APPS = DataRepository::ELEMENTS.select{|i|i.to_s.start_with?(CLIENT_ID_PREFIX)}.freeze
|
|
25
|
+
# cookie prefix so that console can decode identity
|
|
26
|
+
COOKIE_PREFIX_CONSOLE_AOC = 'aspera.aoc'
|
|
27
|
+
# path in URL of public links
|
|
28
|
+
PUBLIC_LINK_PATHS = %w[/packages/public/receive /packages/public/send /files/public /public/files /public/package /public/send].freeze
|
|
29
|
+
JWT_AUDIENCE = 'https://api.asperafiles.com/api/v1/oauth2/token'
|
|
30
|
+
OAUTH_API_SUBPATH = 'api/v1/oauth2'
|
|
31
|
+
# minimum fields for user info if retrieval fails
|
|
32
|
+
USER_INFO_FIELDS_MIN = %w[name email id default_workspace_id organization_id].freeze
|
|
33
|
+
# types of events for shared folder creation
|
|
34
|
+
# Node events: permission.created permission.modified permission.deleted
|
|
35
|
+
PERMISSIONS_CREATED = ['permission.created'].freeze
|
|
36
|
+
|
|
37
|
+
private_constant :MAX_AOC_URL_REDIRECT,
|
|
38
|
+
:CLIENT_ID_PREFIX,
|
|
39
|
+
:GLOBAL_CLIENT_APPS,
|
|
40
|
+
:COOKIE_PREFIX_CONSOLE_AOC,
|
|
41
|
+
:PUBLIC_LINK_PATHS,
|
|
42
|
+
:JWT_AUDIENCE,
|
|
43
|
+
:OAUTH_API_SUBPATH,
|
|
44
|
+
:USER_INFO_FIELDS_MIN,
|
|
45
|
+
:PERMISSIONS_CREATED
|
|
46
|
+
|
|
47
|
+
# various API scopes supported
|
|
48
|
+
SCOPE_FILES_SELF = 'self'
|
|
49
|
+
SCOPE_FILES_USER = 'user:all'
|
|
50
|
+
SCOPE_FILES_ADMIN = 'admin:all'
|
|
51
|
+
SCOPE_FILES_ADMIN_USER = 'admin-user:all'
|
|
52
|
+
SCOPE_FILES_ADMIN_USER_USER = "#{SCOPE_FILES_ADMIN_USER}+#{SCOPE_FILES_USER}"
|
|
53
|
+
FILES_APP = 'files'
|
|
54
|
+
PACKAGES_APP = 'packages'
|
|
55
|
+
API_V1 = 'api/v1'
|
|
56
|
+
|
|
57
|
+
# class static methods
|
|
58
|
+
class << self
|
|
59
|
+
# strings /Applications/Aspera\ Drive.app/Contents/MacOS/AsperaDrive|grep -E '.{100}==$'|base64 --decode
|
|
60
|
+
def get_client_info(client_name=nil)
|
|
61
|
+
client_key = client_name.nil? ? GLOBAL_CLIENT_APPS.first : client_name.to_sym
|
|
62
|
+
return client_key, DataRepository.instance.item(client_key)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# base API url depends on domain, which could be "qa.xxx"
|
|
66
|
+
def api_base_url(organization: 'api', api_domain: PROD_DOMAIN)
|
|
67
|
+
return "https://#{organization}.#{api_domain}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def metering_api(entitlement_id, customer_id, api_domain=PROD_DOMAIN)
|
|
71
|
+
return Rest.new(
|
|
72
|
+
base_url: "#{api_base_url(api_domain: api_domain)}/metering/v1",
|
|
73
|
+
headers: {'X-Aspera-Entitlement-Authorization' => Rest.basic_token(entitlement_id, customer_id)}
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# split host of http://myorg.asperafiles.com in org and domain
|
|
78
|
+
def url_parts(uri)
|
|
79
|
+
raise "No host found in URL.Please check URL format: https://myorg.#{PROD_DOMAIN}" if uri.host.nil?
|
|
80
|
+
parts = uri.host.split('.', 2)
|
|
81
|
+
Aspera.assert(parts.length == 2){"expecting a public FQDN for #{PRODUCT_NAME}"}
|
|
82
|
+
return parts
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @param url [String] URL of AoC public link
|
|
86
|
+
# @return [Hash] information about public link, or nil if not a public link
|
|
87
|
+
def link_info(url)
|
|
88
|
+
final_uri = Rest.new(base_url: url, redirect_max: MAX_AOC_URL_REDIRECT).read('')[:http].uri
|
|
89
|
+
raise 'AoC shall redirect to login page' if final_uri.query.nil?
|
|
90
|
+
decoded_query = Rest.decode_query(final_uri.query)
|
|
91
|
+
# is that a public link ?
|
|
92
|
+
if decoded_query.key?('token')
|
|
93
|
+
Log.log.warn{"Unknown pub link path: #{final_uri.path}"} unless PUBLIC_LINK_PATHS.include?(final_uri.path)
|
|
94
|
+
# ok we get it !
|
|
95
|
+
return {
|
|
96
|
+
instance_domain: url_parts(final_uri)[1],
|
|
97
|
+
url: "https://#{final_uri.host}",
|
|
98
|
+
token: decoded_query['token']
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
Log.log.debug{"path=#{final_uri.path} does not end with /login"} unless final_uri.path.end_with?('/login')
|
|
102
|
+
if decoded_query['state']
|
|
103
|
+
# can be a private link
|
|
104
|
+
state_uri = URI.parse(decoded_query['state'])
|
|
105
|
+
if state_uri.query && decoded_query['redirect_uri']
|
|
106
|
+
decoded_state = Rest.decode_query(state_uri.query)
|
|
107
|
+
if decoded_state.key?('short_link_url')
|
|
108
|
+
if (m = state_uri.path.match(%r{/files/workspaces/([0-9]+)/all/([0-9]+):([0-9]+)}))
|
|
109
|
+
redirect_uri = URI.parse(decoded_query['redirect_uri'])
|
|
110
|
+
parts = url_parts(redirect_uri)
|
|
111
|
+
return {
|
|
112
|
+
instance_domain: parts[1],
|
|
113
|
+
organization: parts[0],
|
|
114
|
+
url: "https://#{redirect_uri.host}",
|
|
115
|
+
private_link: {
|
|
116
|
+
workspace_id: m[1],
|
|
117
|
+
node_id: m[2],
|
|
118
|
+
file_id: m[3]
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
parts = url_parts(URI.parse(url))
|
|
126
|
+
return {
|
|
127
|
+
instance_domain: parts[1],
|
|
128
|
+
organization: parts[0]
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
end # static methods
|
|
132
|
+
|
|
133
|
+
attr_reader :private_link
|
|
134
|
+
|
|
135
|
+
def initialize(subpath: API_V1, url:, auth:, client_id: nil, client_secret: nil, scope: nil, redirect_uri: nil, private_key: nil, passphrase: nil, username: nil,
|
|
136
|
+
password: nil, workspace: nil, secret_finder: nil)
|
|
137
|
+
# test here because link may set url
|
|
138
|
+
raise ArgumentError, 'Missing mandatory option: url' if url.nil?
|
|
139
|
+
raise ArgumentError, 'Missing mandatory option: scope' if scope.nil?
|
|
140
|
+
# default values for client id
|
|
141
|
+
client_id, client_secret = self.class.get_client_info if client_id.nil?
|
|
142
|
+
# access key secrets are provided out of band to get node api access
|
|
143
|
+
# key: access key
|
|
144
|
+
# value: associated secret
|
|
145
|
+
@secret_finder = secret_finder
|
|
146
|
+
@workspace_name = workspace
|
|
147
|
+
@cache_user_info = nil
|
|
148
|
+
@cache_url_token_info = nil
|
|
149
|
+
@context_cache = nil
|
|
150
|
+
auth_params = {
|
|
151
|
+
type: :oauth2,
|
|
152
|
+
client_id: client_id,
|
|
153
|
+
client_secret: client_secret,
|
|
154
|
+
scope: scope
|
|
155
|
+
}
|
|
156
|
+
# analyze type of url
|
|
157
|
+
url_info = AoC.link_info(url)
|
|
158
|
+
Log.log.debug{Log.dump(:url_info, url_info)}
|
|
159
|
+
@private_link = url_info[:private_link]
|
|
160
|
+
auth_params[:grant_method] = if url_info.key?(:token)
|
|
161
|
+
:url_json
|
|
162
|
+
else
|
|
163
|
+
raise ArgumentError, 'Missing mandatory option: auth' if auth.nil?
|
|
164
|
+
auth
|
|
165
|
+
end
|
|
166
|
+
# this is the base API url
|
|
167
|
+
api_url_base = self.class.api_base_url(api_domain: url_info[:instance_domain])
|
|
168
|
+
# auth URL
|
|
169
|
+
auth_params[:base_url] = "#{api_url_base}/#{OAUTH_API_SUBPATH}/#{url_info[:organization]}"
|
|
170
|
+
|
|
171
|
+
# fill other auth parameters based on OAuth method
|
|
172
|
+
case auth_params[:grant_method]
|
|
173
|
+
when :web
|
|
174
|
+
raise ArgumentError, 'Missing mandatory option: redirect_uri' if redirect_uri.nil?
|
|
175
|
+
auth_params[:redirect_uri] = redirect_uri
|
|
176
|
+
when :jwt
|
|
177
|
+
raise ArgumentError, 'Missing mandatory option: private_key' if private_key.nil?
|
|
178
|
+
raise ArgumentError, 'Missing mandatory option: username' if username.nil?
|
|
179
|
+
auth_params[:private_key_obj] = OpenSSL::PKey::RSA.new(private_key, passphrase)
|
|
180
|
+
auth_params[:payload] = {
|
|
181
|
+
iss: auth_params[:client_id], # issuer
|
|
182
|
+
sub: username, # subject
|
|
183
|
+
aud: JWT_AUDIENCE
|
|
184
|
+
}
|
|
185
|
+
# add jwt payload for global client id
|
|
186
|
+
auth_params[:payload][:org] = url_info[:organization] if GLOBAL_CLIENT_APPS.include?(auth_params[:client_id])
|
|
187
|
+
when :url_json
|
|
188
|
+
auth_params[:url] = {grant_type: 'url_token'} # URL arguments
|
|
189
|
+
auth_params[:json] = {url_token: url_info[:token]} # JSON body
|
|
190
|
+
# password protection of link
|
|
191
|
+
auth_params[:json][:password] = password unless password.nil?
|
|
192
|
+
# basic auth required for /token
|
|
193
|
+
auth_params[:auth] = {type: :basic, username: auth_params[:client_id], password: auth_params[:client_secret]}
|
|
194
|
+
else Aspera.error_unexpected_value(auth_params[:grant_method])
|
|
195
|
+
end
|
|
196
|
+
super(
|
|
197
|
+
base_url: "#{api_url_base}/#{subpath}",
|
|
198
|
+
auth: auth_params
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def public_link
|
|
203
|
+
return nil unless auth_params[:grant_method].eql?(:url_json)
|
|
204
|
+
return @cache_url_token_info unless @cache_url_token_info.nil?
|
|
205
|
+
# TODO: can there be several in list ?
|
|
206
|
+
@cache_url_token_info = read('url_tokens')[:data].first
|
|
207
|
+
return @cache_url_token_info
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def assert_public_link_types(expected)
|
|
211
|
+
Aspera.assert_values(public_link['purpose'], expected){'public link type'}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def additional_persistence_ids
|
|
215
|
+
return [current_user_info['id']] if public_link.nil?
|
|
216
|
+
return [] # TODO : public_link['id'] ?
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# cached user information
|
|
220
|
+
def current_user_info(exception: false)
|
|
221
|
+
return @cache_user_info unless @cache_user_info.nil?
|
|
222
|
+
# get our user's default information
|
|
223
|
+
@cache_user_info =
|
|
224
|
+
begin
|
|
225
|
+
read('self')[:data]
|
|
226
|
+
rescue StandardError => e
|
|
227
|
+
raise e if exception
|
|
228
|
+
Log.log.debug{"ignoring error: #{e}"}
|
|
229
|
+
{}
|
|
230
|
+
end
|
|
231
|
+
USER_INFO_FIELDS_MIN.each{|f|@cache_user_info[f] = nil if @cache_user_info[f].nil?}
|
|
232
|
+
return @cache_user_info
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# @param application [Symbol] :files or :packages
|
|
236
|
+
# @return [Hash] current context information: workspace, and home node/file if app is "Files"
|
|
237
|
+
def context(application = nil)
|
|
238
|
+
return @context_cache unless @context_cache.nil?
|
|
239
|
+
Aspera.assert(!application.nil?){'application must be set once'}
|
|
240
|
+
Aspera.assert_values(application, %i[files packages])
|
|
241
|
+
ws_id =
|
|
242
|
+
if !public_link.nil?
|
|
243
|
+
Log.log.debug('Using workspace of public link')
|
|
244
|
+
public_link['data']['workspace_id']
|
|
245
|
+
elsif !private_link.nil?
|
|
246
|
+
Log.log.debug('Using workspace of private link')
|
|
247
|
+
private_link[:workspace_id]
|
|
248
|
+
elsif @workspace_name.eql?(DEFAULT_WORKSPACE)
|
|
249
|
+
Log.log.debug('Using default workspace'.green)
|
|
250
|
+
raise 'User does not have default workspace, please specify workspace' if current_user_info['default_workspace_id'].nil?
|
|
251
|
+
current_user_info['default_workspace_id']
|
|
252
|
+
elsif @workspace_name.nil?
|
|
253
|
+
nil
|
|
254
|
+
else
|
|
255
|
+
lookup_by_name('workspaces', @workspace_name)['id']
|
|
256
|
+
end
|
|
257
|
+
ws_info =
|
|
258
|
+
if ws_id.nil?
|
|
259
|
+
nil
|
|
260
|
+
else
|
|
261
|
+
read("workspaces/#{ws_id}")[:data]
|
|
262
|
+
end
|
|
263
|
+
@context_cache = if ws_info.nil?
|
|
264
|
+
{
|
|
265
|
+
workspace_id: nil,
|
|
266
|
+
workspace_name: 'Shared folders'
|
|
267
|
+
}
|
|
268
|
+
else
|
|
269
|
+
{
|
|
270
|
+
workspace_id: ws_info['id'],
|
|
271
|
+
workspace_name: ws_info['name']
|
|
272
|
+
}
|
|
273
|
+
end
|
|
274
|
+
return @context_cache unless application.eql?(:files)
|
|
275
|
+
if !public_link.nil?
|
|
276
|
+
assert_public_link_types(['view_shared_file'])
|
|
277
|
+
@context_cache[:home_node_id] = public_link['data']['node_id']
|
|
278
|
+
@context_cache[:home_file_id] = public_link['data']['file_id']
|
|
279
|
+
elsif !private_link.nil?
|
|
280
|
+
@context_cache[:home_node_id] = private_link[:node_id]
|
|
281
|
+
@context_cache[:home_file_id] = private_link[:file_id]
|
|
282
|
+
elsif ws_info
|
|
283
|
+
@context_cache[:home_node_id] = ws_info['home_node_id']
|
|
284
|
+
@context_cache[:home_file_id] = ws_info['home_file_id']
|
|
285
|
+
else
|
|
286
|
+
# not part of any workspace, but has some folder shared
|
|
287
|
+
user_info = current_user_info(exception: true) rescue {'read_only_home_node_id' => nil, 'read_only_home_file_id' => nil}
|
|
288
|
+
@context_cache[:home_node_id] = user_info['read_only_home_node_id']
|
|
289
|
+
@context_cache[:home_file_id] = user_info['read_only_home_file_id']
|
|
290
|
+
end
|
|
291
|
+
raise "Cannot get user's home node id, check your default workspace or specify one" if @context_cache[:home_node_id].to_s.empty?
|
|
292
|
+
Log.log.debug{Log.dump(:context, @context_cache)}
|
|
293
|
+
return @context_cache
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# @param node_id [String] identifier of node in AoC
|
|
297
|
+
# @param workspace_id [String] workspace identifier
|
|
298
|
+
# @param workspace_name [String] workspace name
|
|
299
|
+
# @param scope e.g. Node::SCOPE_USER, or nil (requires secret)
|
|
300
|
+
# @param package_info [Hash] created package information
|
|
301
|
+
# @returns [Node] a node API for access key
|
|
302
|
+
def node_api_from(node_id:, workspace_id: nil, workspace_name: nil, scope: Node::SCOPE_USER, package_info: nil)
|
|
303
|
+
Aspera.assert_type(node_id, String)
|
|
304
|
+
node_info = read("nodes/#{node_id}")[:data]
|
|
305
|
+
if workspace_name.nil? && !workspace_id.nil?
|
|
306
|
+
workspace_name = read("workspaces/#{workspace_id}")[:data]['name']
|
|
307
|
+
end
|
|
308
|
+
app_info = {
|
|
309
|
+
api: self, # for callback
|
|
310
|
+
app: package_info.nil? ? FILES_APP : PACKAGES_APP,
|
|
311
|
+
node_info: node_info,
|
|
312
|
+
workspace_id: workspace_id,
|
|
313
|
+
workspace_name: workspace_name
|
|
314
|
+
}
|
|
315
|
+
if PACKAGES_APP.eql?(app_info[:app])
|
|
316
|
+
raise 'package info required' if package_info.nil?
|
|
317
|
+
app_info[:package_id] = package_info['id']
|
|
318
|
+
app_info[:package_name] = package_info['name']
|
|
319
|
+
end
|
|
320
|
+
node_params = {base_url: node_info['url']}
|
|
321
|
+
# if secret is available
|
|
322
|
+
if scope.nil?
|
|
323
|
+
node_params[:auth] = {
|
|
324
|
+
type: :basic,
|
|
325
|
+
username: node_info['access_key'],
|
|
326
|
+
password: @secret_finder&.lookup_secret(url: node_info['url'], username: node_info['access_key'], mandatory: true)
|
|
327
|
+
}
|
|
328
|
+
else
|
|
329
|
+
# OAuth bearer token
|
|
330
|
+
node_params[:auth] = auth_params.clone
|
|
331
|
+
node_params[:auth][:scope] = Node.token_scope(node_info['access_key'], scope)
|
|
332
|
+
# special header required for bearer token only
|
|
333
|
+
node_params[:headers] = {Node::HEADER_X_ASPERA_ACCESS_KEY => node_info['access_key']}
|
|
334
|
+
end
|
|
335
|
+
node_params[:app_info] = app_info
|
|
336
|
+
return Node.new(**node_params)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Check metadata: remove when validation is done server side
|
|
340
|
+
def validate_metadata(pkg_data)
|
|
341
|
+
# validate only for shared inboxes
|
|
342
|
+
return unless pkg_data['recipients'].is_a?(Array) &&
|
|
343
|
+
pkg_data['recipients'].first.is_a?(Hash) &&
|
|
344
|
+
pkg_data['recipients'].first.key?('type') &&
|
|
345
|
+
pkg_data['recipients'].first['type'].eql?('dropbox')
|
|
346
|
+
meta_schema = read("dropboxes/#{pkg_data['recipients'].first['id']}")[:data]['metadata_schema']
|
|
347
|
+
if meta_schema.nil? || meta_schema.empty?
|
|
348
|
+
Log.log.debug('no metadata in shared inbox')
|
|
349
|
+
return
|
|
350
|
+
end
|
|
351
|
+
Aspera.assert(pkg_data.key?('metadata')){"package requires metadata: #{meta_schema}"}
|
|
352
|
+
pkg_meta = pkg_data['metadata']
|
|
353
|
+
Aspera.assert_type(pkg_meta, Array){'metadata'}
|
|
354
|
+
Log.log.debug{Log.dump(:metadata, pkg_meta)}
|
|
355
|
+
pkg_meta.each do |field|
|
|
356
|
+
Aspera.assert_type(field, Hash){'metadata field'}
|
|
357
|
+
Aspera.assert(field.key?('name')){'metadata field must have name'}
|
|
358
|
+
Aspera.assert(field.key?('values')){'metadata field must have values'}
|
|
359
|
+
Aspera.assert_type(field['values'], Array){'metadata field values'}
|
|
360
|
+
Aspera.assert(!meta_schema.none?{|i|i['name'].eql?(field['name'])}){"unknown metadata field: #{field['name']}"}
|
|
361
|
+
end
|
|
362
|
+
meta_schema.each do |field|
|
|
363
|
+
provided = pkg_meta.select{|i|i['name'].eql?(field['name'])}
|
|
364
|
+
raise "only one field with name #{field['name']} allowed" if provided.count > 1
|
|
365
|
+
raise "missing mandatory field: #{field['name']}" if field['required'] && provided.empty?
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Normalize package creation recipient lists as expected by AoC API
|
|
370
|
+
# AoC expects {type: , id: }, but ascli allows providing either the native values or just a name
|
|
371
|
+
# in that case, the name is resolved and replaced with {type: , id: }
|
|
372
|
+
# @param package_data The whole package creation payload
|
|
373
|
+
# @param recipient_list_field The field in structure, i.e. recipients or bcc_recipients
|
|
374
|
+
# @return nil package_data is modified
|
|
375
|
+
def resolve_package_recipients(package_data, ws_id, recipient_list_field, new_user_option)
|
|
376
|
+
return unless package_data.key?(recipient_list_field)
|
|
377
|
+
Aspera.assert_type(package_data[recipient_list_field], Array){recipient_list_field}
|
|
378
|
+
new_user_option = {'package_contact' => true} if new_user_option.nil?
|
|
379
|
+
Aspera.assert_type(new_user_option, Hash){'new_user_option'}
|
|
380
|
+
# list with resolved elements
|
|
381
|
+
resolved_list = []
|
|
382
|
+
package_data[recipient_list_field].each do |short_recipient_info|
|
|
383
|
+
case short_recipient_info
|
|
384
|
+
when Hash # native API information, check keys
|
|
385
|
+
Aspera.assert(short_recipient_info.keys.sort.eql?(%w[id type])){"#{recipient_list_field} element shall have fields: id and type"}
|
|
386
|
+
when String # CLI helper: need to resolve provided name to type/id
|
|
387
|
+
# email: user, else dropbox
|
|
388
|
+
entity_type = short_recipient_info.include?('@') ? 'contacts' : 'dropboxes'
|
|
389
|
+
begin
|
|
390
|
+
full_recipient_info = lookup_by_name(entity_type, short_recipient_info, {'current_workspace_id' => ws_id})
|
|
391
|
+
rescue RuntimeError => e
|
|
392
|
+
raise e unless e.message.start_with?(ENTITY_NOT_FOUND)
|
|
393
|
+
# dropboxes cannot be created on the fly
|
|
394
|
+
raise "No such shared inbox in workspace #{ws_id}" if entity_type.eql?('dropboxes')
|
|
395
|
+
# unknown user: create it as external user
|
|
396
|
+
full_recipient_info = create('contacts', {
|
|
397
|
+
'current_workspace_id' => ws_id,
|
|
398
|
+
'email' => short_recipient_info
|
|
399
|
+
}.merge(new_user_option))[:data]
|
|
400
|
+
end
|
|
401
|
+
short_recipient_info = if entity_type.eql?('dropboxes')
|
|
402
|
+
{'id' => full_recipient_info['id'], 'type' => 'dropbox'}
|
|
403
|
+
else
|
|
404
|
+
{'id' => full_recipient_info['source_id'], 'type' => full_recipient_info['source_type']}
|
|
405
|
+
end
|
|
406
|
+
else # unexpected extended value, must be String or Hash
|
|
407
|
+
raise "#{recipient_list_field} item must be a String (email, shared inbox) or Hash (id,type)"
|
|
408
|
+
end # type of recipient info
|
|
409
|
+
# add original or resolved recipient info
|
|
410
|
+
resolved_list.push(short_recipient_info)
|
|
411
|
+
end
|
|
412
|
+
# replace with resolved elements
|
|
413
|
+
package_data[recipient_list_field] = resolved_list
|
|
414
|
+
return nil
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# CLI allows simplified format for metadata: transform if necessary for API
|
|
418
|
+
def update_package_metadata_for_api(pkg_data)
|
|
419
|
+
case pkg_data['metadata']
|
|
420
|
+
when Array, NilClass # no action
|
|
421
|
+
when Hash
|
|
422
|
+
api_meta = []
|
|
423
|
+
pkg_data['metadata'].each do |k, v|
|
|
424
|
+
api_meta.push({
|
|
425
|
+
# 'input_type' => 'single-dropdown',
|
|
426
|
+
'name' => k,
|
|
427
|
+
'values' => v.is_a?(Array) ? v : [v]
|
|
428
|
+
})
|
|
429
|
+
end
|
|
430
|
+
pkg_data['metadata'] = api_meta
|
|
431
|
+
else Aspera.error_unexpected_value(pkg_meta.class)
|
|
432
|
+
end
|
|
433
|
+
return nil
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# create a package
|
|
437
|
+
# @param package_data [Hash] package creation (with extensions...)
|
|
438
|
+
# @param validate_meta [TrueClass,FalseClass] true to validate parameters locally
|
|
439
|
+
# @param new_user_option [Hash] options if an unknown user is specified
|
|
440
|
+
# @return transfer spec, node api and package information
|
|
441
|
+
def create_package_simple(package_data, validate_meta, new_user_option)
|
|
442
|
+
update_package_metadata_for_api(package_data)
|
|
443
|
+
# list of files to include in package, optional
|
|
444
|
+
# package_data['file_names']||=[..list of filenames to transfer...]
|
|
445
|
+
|
|
446
|
+
# lookup users
|
|
447
|
+
resolve_package_recipients(package_data, package_data['workspace_id'], 'recipients', new_user_option)
|
|
448
|
+
resolve_package_recipients(package_data, package_data['workspace_id'], 'bcc_recipients', new_user_option)
|
|
449
|
+
|
|
450
|
+
validate_metadata(package_data) if validate_meta
|
|
451
|
+
|
|
452
|
+
# create a new package container
|
|
453
|
+
created_package = create('packages', package_data)[:data]
|
|
454
|
+
|
|
455
|
+
package_node_api = node_api_from(
|
|
456
|
+
node_id: created_package['node_id'],
|
|
457
|
+
workspace_id: created_package['workspace_id'],
|
|
458
|
+
package_info: created_package)
|
|
459
|
+
|
|
460
|
+
# tell AoC what to expect in package: 1 transfer (can also be done after transfer)
|
|
461
|
+
# TODO: if multi session was used we should probably tell
|
|
462
|
+
# also, currently no "multi-source" , i.e. only from client-side files, unless "node" agent is used
|
|
463
|
+
update("packages/#{created_package['id']}", {'sent' => true, 'transfers_expected' => 1})[:data]
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
spec: package_node_api.transfer_spec_gen4(created_package['contents_file_id'], Transfer::Spec::DIRECTION_SEND),
|
|
467
|
+
node: package_node_api,
|
|
468
|
+
info: created_package
|
|
469
|
+
}
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Add transfer spec
|
|
473
|
+
# callback in Node (transfer_spec_gen4)
|
|
474
|
+
def add_ts_tags(transfer_spec:, app_info:)
|
|
475
|
+
# translate transfer direction to upload/download
|
|
476
|
+
transfer_type = Transfer::Spec.action(transfer_spec)
|
|
477
|
+
# Analytics tags
|
|
478
|
+
################
|
|
479
|
+
transfer_spec.deep_merge!({
|
|
480
|
+
'tags' => {
|
|
481
|
+
Transfer::Spec::TAG_RESERVED => {
|
|
482
|
+
'usage_id' => "aspera.files.workspace.#{app_info[:workspace_id]}", # activity tracking
|
|
483
|
+
'files' => {
|
|
484
|
+
'files_transfer_action' => "#{transfer_type}_#{app_info[:app].gsub(/s$/, '')}",
|
|
485
|
+
'workspace_name' => app_info[:workspace_name], # activity tracking
|
|
486
|
+
'workspace_id' => app_info[:workspace_id]
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
})
|
|
491
|
+
# Console cookie
|
|
492
|
+
################
|
|
493
|
+
# we are sure that fields are not nil
|
|
494
|
+
cookie_elements = [app_info[:app], current_user_info['name'] || 'public link', current_user_info['email'] || 'none'].map{|e|Base64.strict_encode64(e)}
|
|
495
|
+
cookie_elements.unshift(COOKIE_PREFIX_CONSOLE_AOC)
|
|
496
|
+
transfer_spec['cookie'] = cookie_elements.join(':')
|
|
497
|
+
# Application tags
|
|
498
|
+
##################
|
|
499
|
+
case app_info[:app]
|
|
500
|
+
when FILES_APP
|
|
501
|
+
file_id = transfer_spec['tags'][Transfer::Spec::TAG_RESERVED]['node']['file_id']
|
|
502
|
+
transfer_spec.deep_merge!({'tags' => {Transfer::Spec::TAG_RESERVED => {'files' => {'parentCwd' => "#{app_info[:node_info]['id']}:#{file_id}"}}}}) \
|
|
503
|
+
unless transfer_spec.key?('remote_access_key')
|
|
504
|
+
when PACKAGES_APP
|
|
505
|
+
transfer_spec.deep_merge!({
|
|
506
|
+
'tags' => {
|
|
507
|
+
Transfer::Spec::TAG_RESERVED => {
|
|
508
|
+
'files' => {
|
|
509
|
+
'package_id' => app_info[:package_id],
|
|
510
|
+
'package_name' => app_info[:package_name],
|
|
511
|
+
'package_operation' => transfer_type
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
})
|
|
516
|
+
end
|
|
517
|
+
transfer_spec['tags'][Transfer::Spec::TAG_RESERVED]['files']['node_id'] = app_info[:node_info]['id']
|
|
518
|
+
transfer_spec['tags'][Transfer::Spec::TAG_RESERVED]['app'] = app_info[:app]
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
ID_AK_ADMIN = 'ASPERA_ACCESS_KEY_ADMIN'
|
|
522
|
+
# Callback from Plugins::Node
|
|
523
|
+
# add application specific tags to permissions creation
|
|
524
|
+
# @param create_param [Hash] parameters for creating permissions
|
|
525
|
+
# @param app_info [Hash] application information
|
|
526
|
+
def permissions_set_create_params(create_param:, app_info:)
|
|
527
|
+
# workspace shared folder:
|
|
528
|
+
# access_id = "#{ID_AK_ADMIN}_WS_#{app_info[:workspace_id]}"
|
|
529
|
+
default_params = {
|
|
530
|
+
# 'access_type' => 'user', # mandatory: user or group
|
|
531
|
+
# 'access_id' => access_id, # id of user or group
|
|
532
|
+
'tags' => {
|
|
533
|
+
Transfer::Spec::TAG_RESERVED => {
|
|
534
|
+
'files' => {
|
|
535
|
+
'workspace' => {
|
|
536
|
+
'id' => app_info[:workspace_id],
|
|
537
|
+
'workspace_name' => app_info[:workspace_name],
|
|
538
|
+
'user_name' => current_user_info['name'],
|
|
539
|
+
'shared_by_user_id' => current_user_info['id'],
|
|
540
|
+
'shared_by_name' => current_user_info['name'],
|
|
541
|
+
'shared_by_email' => current_user_info['email'],
|
|
542
|
+
# 'shared_with_name' => access_id,
|
|
543
|
+
'access_key' => app_info[:node_info]['access_key'],
|
|
544
|
+
'node' => app_info[:node_info]['name']
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
create_param.deep_merge!(default_params)
|
|
551
|
+
if create_param.key?('with')
|
|
552
|
+
contact_info = lookup_by_name(
|
|
553
|
+
'contacts',
|
|
554
|
+
create_param['with'],
|
|
555
|
+
{'current_workspace_id' => app_info[:workspace_id], 'context' => 'share_folder'})
|
|
556
|
+
create_param.delete('with')
|
|
557
|
+
create_param['access_type'] = contact_info['source_type']
|
|
558
|
+
create_param['access_id'] = contact_info['source_id']
|
|
559
|
+
create_param['tags'][Transfer::Spec::TAG_RESERVED]['files']['workspace']['shared_with_name'] = contact_info['email']
|
|
560
|
+
end
|
|
561
|
+
# optional
|
|
562
|
+
app_info[:opt_link_name] = create_param.delete('link_name')
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
# Callback from Plugins::Node
|
|
566
|
+
# send shared folder event to AoC
|
|
567
|
+
# @param created_data [Hash] response from permission creation
|
|
568
|
+
# @param app_info [Hash] hash with app info
|
|
569
|
+
# @param types [Array] event types
|
|
570
|
+
def permissions_send_event(created_data:, app_info:, types: PERMISSIONS_CREATED)
|
|
571
|
+
Aspera.assert_type(types, Array)
|
|
572
|
+
Aspera.assert(!types.empty?)
|
|
573
|
+
event_creation = {
|
|
574
|
+
'types' => types,
|
|
575
|
+
'node_id' => app_info[:node_info]['id'],
|
|
576
|
+
'workspace_id' => app_info[:workspace_id],
|
|
577
|
+
'data' => created_data
|
|
578
|
+
}
|
|
579
|
+
# (optional). The name of the folder to be displayed to the destination user.
|
|
580
|
+
# Use it if its value is different from the "share_as" field.
|
|
581
|
+
event_creation['link_name'] = app_info[:opt_link_name] unless app_info[:opt_link_name].nil?
|
|
582
|
+
create('events', event_creation)
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/log'
|
|
4
|
+
require 'aspera/rest'
|
|
5
|
+
|
|
6
|
+
module Aspera
|
|
7
|
+
module Api
|
|
8
|
+
class Ats < Aspera::Rest
|
|
9
|
+
SERVICE_BASE_URL = 'https://ats.aspera.io'
|
|
10
|
+
# currently supported clouds
|
|
11
|
+
# Note to Aspera: shall be an API call
|
|
12
|
+
CLOUD_NAME = {
|
|
13
|
+
aws: 'Amazon Web Services',
|
|
14
|
+
azure: 'Microsoft Azure',
|
|
15
|
+
google: 'Google Cloud',
|
|
16
|
+
limelight: 'Limelight',
|
|
17
|
+
rackspace: 'Rackspace',
|
|
18
|
+
softlayer: 'IBM Cloud'
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
private_constant :CLOUD_NAME
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
super(base_url: "#{SERVICE_BASE_URL}/pub/v1")
|
|
25
|
+
# cache of server data
|
|
26
|
+
@all_servers_cache = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def cloud_names; CLOUD_NAME; end
|
|
30
|
+
|
|
31
|
+
# all available ATS servers
|
|
32
|
+
# NOTE to Aspera: an API shall be created to retrieve all servers at once
|
|
33
|
+
def all_servers
|
|
34
|
+
if @all_servers_cache.nil?
|
|
35
|
+
@all_servers_cache = []
|
|
36
|
+
CLOUD_NAME.each_key do |name|
|
|
37
|
+
read("servers/#{name.to_s.upcase}")[:data].each do |i|
|
|
38
|
+
@all_servers_cache.push(i)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
return @all_servers_cache
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|