1
|
|
|
<?php |
2
|
|
|
/** |
3
|
|
|
* Spiral Framework. |
4
|
|
|
* |
5
|
|
|
* @license MIT |
6
|
|
|
* @author Anton Titov (Wolfy-J) |
7
|
|
|
*/ |
8
|
|
|
|
9
|
|
|
namespace Spiral\Storage\Servers; |
10
|
|
|
|
11
|
|
|
use GuzzleHttp\Client; |
12
|
|
|
use GuzzleHttp\ClientInterface; |
13
|
|
|
use GuzzleHttp\Exception\ClientException; |
14
|
|
|
use GuzzleHttp\Psr7\Request; |
15
|
|
|
use GuzzleHttp\Psr7\Uri; |
16
|
|
|
use Psr\Http\Message\RequestInterface; |
17
|
|
|
use Psr\Http\Message\ResponseInterface; |
18
|
|
|
use Psr\Http\Message\StreamInterface; |
19
|
|
|
use Psr\Http\Message\UriInterface; |
20
|
|
|
use Psr\Log\LoggerAwareInterface; |
21
|
|
|
use Psr\SimpleCache\CacheInterface; |
22
|
|
|
use Spiral\Debug\Traits\LoggerTrait; |
23
|
|
|
use Spiral\Files\FilesInterface; |
24
|
|
|
use Spiral\Storage\BucketInterface; |
25
|
|
|
use Spiral\Storage\Exceptions\ServerException; |
26
|
|
|
|
27
|
|
|
/** |
28
|
|
|
* Provides abstraction level to work with data located in Rackspace cloud. |
29
|
|
|
*/ |
30
|
|
|
class RackspaceServer extends AbstractServer implements LoggerAwareInterface |
31
|
|
|
{ |
32
|
|
|
use LoggerTrait; |
33
|
|
|
|
34
|
|
|
/** |
35
|
|
|
* @var string |
36
|
|
|
*/ |
37
|
|
|
private $authToken = []; |
38
|
|
|
|
39
|
|
|
/** |
40
|
|
|
* Some operations can be performed only inside one region. |
41
|
|
|
* |
42
|
|
|
* @var array |
43
|
|
|
*/ |
44
|
|
|
private $regions = []; |
45
|
|
|
|
46
|
|
|
/** |
47
|
|
|
* @var array |
48
|
|
|
*/ |
49
|
|
|
protected $options = [ |
50
|
|
|
'server' => 'https://auth.api.rackspacecloud.com/v1.0', |
51
|
|
|
'authServer' => 'https://identity.api.rackspacecloud.com/v2.0/tokens', |
52
|
|
|
'username' => '', |
53
|
|
|
'apiKey' => '', |
54
|
|
|
'cache' => true, |
55
|
|
|
'lifetime' => 86400 |
56
|
|
|
]; |
57
|
|
|
|
58
|
|
|
/** |
59
|
|
|
* Cache store to remember connection. |
60
|
|
|
* |
61
|
|
|
* @invisible |
62
|
|
|
* @var CacheInterface |
63
|
|
|
*/ |
64
|
|
|
protected $cache = null; |
65
|
|
|
|
66
|
|
|
/** |
67
|
|
|
* @var ClientInterface |
68
|
|
|
*/ |
69
|
|
|
protected $client = null; |
70
|
|
|
|
71
|
|
|
/** |
72
|
|
|
* @param array $options |
73
|
|
|
* @param CacheInterface|null $cache |
74
|
|
|
* @param FilesInterface|null $files |
75
|
|
|
* @param ClientInterface|null $client |
76
|
|
|
*/ |
77
|
|
|
public function __construct( |
78
|
|
|
array $options, |
79
|
|
|
CacheInterface $cache = null, |
80
|
|
|
FilesInterface $files = null, |
81
|
|
|
|
82
|
|
|
ClientInterface $client = null |
83
|
|
|
) { |
84
|
|
|
parent::__construct($options, $files); |
85
|
|
|
$this->cache = $cache; |
86
|
|
|
|
87
|
|
|
if (!empty($this->cache) && $this->options['cache']) { |
88
|
|
|
$this->authToken = $this->cache->get( |
89
|
|
|
$this->options['username'] . '@rackspace-token' |
90
|
|
|
); |
91
|
|
|
|
92
|
|
|
$this->regions = (array)$this->cache->get( |
93
|
|
|
$this->options['username'] . '@rackspace-regions' |
94
|
|
|
); |
95
|
|
|
} |
96
|
|
|
|
97
|
|
|
//Initiating Guzzle |
98
|
|
|
$this->setClient($client ?? new Client($this->options))->connect(); |
99
|
|
|
} |
100
|
|
|
|
101
|
|
|
/** |
102
|
|
|
* @param ClientInterface $client |
103
|
|
|
* |
104
|
|
|
* @return self |
105
|
|
|
*/ |
106
|
|
|
public function setClient(ClientInterface $client): RackspaceServer |
107
|
|
|
{ |
108
|
|
|
$this->client = $client; |
109
|
|
|
|
110
|
|
|
return $this; |
111
|
|
|
} |
112
|
|
|
|
113
|
|
|
/** |
114
|
|
|
* {@inheritdoc} |
115
|
|
|
* |
116
|
|
|
* @param ResponseInterface $response Reference. |
117
|
|
|
* |
118
|
|
|
* @return bool |
119
|
|
|
*/ |
120
|
|
|
public function exists( |
121
|
|
|
BucketInterface $bucket, |
122
|
|
|
string $name, |
123
|
|
|
ResponseInterface &$response = null |
124
|
|
|
): bool { |
125
|
|
|
try { |
126
|
|
|
$response = $this->client->send($this->buildRequest('HEAD', $bucket, $name)); |
127
|
|
|
} catch (ClientException $e) { |
128
|
|
|
if ($e->getCode() == 404) { |
129
|
|
|
return false; |
130
|
|
|
} |
131
|
|
|
|
132
|
|
|
if ($e->getCode() == 401) { |
133
|
|
|
$this->reconnect(); |
134
|
|
|
|
135
|
|
|
//Retry |
136
|
|
|
return $this->exists($bucket, $name, $response); |
137
|
|
|
} |
138
|
|
|
|
139
|
|
|
//Some unexpected error |
140
|
|
|
throw new ServerException($e->getMessage(), $e->getCode(), $e); |
141
|
|
|
} |
142
|
|
|
|
143
|
|
|
if ($response->getStatusCode() !== 200) { |
144
|
|
|
return false; |
145
|
|
|
} |
146
|
|
|
|
147
|
|
|
return true; |
148
|
|
|
} |
149
|
|
|
|
150
|
|
|
/** |
151
|
|
|
* {@inheritdoc} |
152
|
|
|
*/ |
153
|
|
|
public function size(BucketInterface $bucket, string $name) |
154
|
|
|
{ |
155
|
|
|
if (!$this->exists($bucket, $name, $response)) { |
156
|
|
|
return null; |
157
|
|
|
} |
158
|
|
|
|
159
|
|
|
/** |
160
|
|
|
* @var ResponseInterface $response |
161
|
|
|
*/ |
162
|
|
|
return (int)$response->getHeaderLine('Content-Length'); |
163
|
|
|
} |
164
|
|
|
|
165
|
|
|
/** |
166
|
|
|
* {@inheritdoc} |
167
|
|
|
*/ |
168
|
|
|
public function put(BucketInterface $bucket, string $name, $source): bool |
169
|
|
|
{ |
170
|
|
|
if (empty($mimetype = \GuzzleHttp\Psr7\mimetype_from_filename($name))) { |
171
|
|
|
$mimetype = self::DEFAULT_MIMETYPE; |
172
|
|
|
} |
173
|
|
|
|
174
|
|
|
try { |
175
|
|
|
$request = $this->buildRequest('PUT', $bucket, $name, [ |
176
|
|
|
'Content-Type' => $mimetype, |
177
|
|
|
'Etag' => md5_file($this->castFilename($source)) |
178
|
|
|
]); |
179
|
|
|
|
180
|
|
|
$this->client->send($request->withBody($this->castStream($source))); |
181
|
|
|
} catch (ClientException $e) { |
182
|
|
|
if ($e->getCode() == 401) { |
183
|
|
|
$this->reconnect(); |
184
|
|
|
|
185
|
|
|
return $this->put($bucket, $name, $source); |
186
|
|
|
} |
187
|
|
|
|
188
|
|
|
//Some unexpected error |
189
|
|
|
throw new ServerException($e->getMessage(), $e->getCode(), $e); |
190
|
|
|
} |
191
|
|
|
|
192
|
|
|
return true; |
193
|
|
|
} |
194
|
|
|
|
195
|
|
|
/** |
196
|
|
|
* {@inheritdoc} |
197
|
|
|
*/ |
198
|
|
|
public function allocateStream(BucketInterface $bucket, string $name): StreamInterface |
199
|
|
|
{ |
200
|
|
|
try { |
201
|
|
|
$response = $this->client->send($this->buildRequest('GET', $bucket, $name)); |
202
|
|
|
} catch (ClientException $e) { |
203
|
|
|
if ($e->getCode() == 401) { |
204
|
|
|
$this->reconnect(); |
205
|
|
|
|
206
|
|
|
return $this->allocateStream($bucket, $name); |
207
|
|
|
} |
208
|
|
|
|
209
|
|
|
throw new ServerException($e->getMessage(), $e->getCode(), $e); |
210
|
|
|
} |
211
|
|
|
|
212
|
|
|
return $response->getBody(); |
213
|
|
|
} |
214
|
|
|
|
215
|
|
|
/** |
216
|
|
|
* {@inheritdoc} |
217
|
|
|
* |
218
|
|
|
* @todo debug to figure out why Rackspace is not reliable |
219
|
|
|
* @see https://github.com/rackspace/php-opencloud/issues/477 |
220
|
|
|
*/ |
221
|
|
|
public function delete(BucketInterface $bucket, string $name, bool $retry = true) |
222
|
|
|
{ |
223
|
|
|
if (!$this->exists($bucket, $name)) { |
224
|
|
|
throw new ServerException("Unable to delete object, file not found"); |
225
|
|
|
} |
226
|
|
|
|
227
|
|
|
try { |
228
|
|
|
$this->client->send($this->buildRequest('DELETE', $bucket, $name)); |
229
|
|
|
} catch (ClientException $e) { |
230
|
|
|
if ($e->getCode() == 409 && $retry) { |
231
|
|
|
|
232
|
|
|
//Giving retry in 0.5 seconds, hate myself for doing so |
233
|
|
|
usleep(500000); |
234
|
|
|
$this->delete($bucket, $name, false); |
235
|
|
|
|
236
|
|
|
} elseif ($e->getCode() == 401) { |
237
|
|
|
$this->reconnect(); |
238
|
|
|
$this->delete($bucket, $name); |
239
|
|
|
} elseif ($e->getCode() != 404) { |
240
|
|
|
throw new ServerException($e->getMessage(), $e->getCode(), $e); |
241
|
|
|
} |
242
|
|
|
} |
243
|
|
|
} |
244
|
|
|
|
245
|
|
|
/** |
246
|
|
|
* {@inheritdoc} |
247
|
|
|
*/ |
248
|
|
|
public function rename(BucketInterface $bucket, string $oldName, string $newName): bool |
249
|
|
|
{ |
250
|
|
|
try { |
251
|
|
|
$request = $this->buildRequest('PUT', $bucket, $newName, [ |
252
|
|
|
'X-Copy-From' => '/' . $bucket->getOption('container') . '/' . rawurlencode($oldName), |
253
|
|
|
'Content-Length' => 0 |
254
|
|
|
]); |
255
|
|
|
|
256
|
|
|
$this->client->send($request); |
257
|
|
|
} catch (ClientException $e) { |
258
|
|
|
if ($e->getCode() == 401) { |
259
|
|
|
$this->reconnect(); |
260
|
|
|
|
261
|
|
|
return $this->rename($bucket, $oldName, $newName); |
262
|
|
|
} |
263
|
|
|
|
264
|
|
|
throw new ServerException($e->getMessage(), $e->getCode(), $e); |
265
|
|
|
} |
266
|
|
|
|
267
|
|
|
//Deleting old file |
268
|
|
|
$this->delete($bucket, $oldName); |
269
|
|
|
|
270
|
|
|
return true; |
271
|
|
|
} |
272
|
|
|
|
273
|
|
|
/** |
274
|
|
|
* {@inheritdoc} |
275
|
|
|
*/ |
276
|
|
|
public function copy(BucketInterface $bucket, BucketInterface $destination, string $name): bool |
277
|
|
|
{ |
278
|
|
|
if ($bucket->getOption('region') != $destination->getOption('region')) { |
279
|
|
|
$this->logger()->warning( |
280
|
|
|
"Copying between regions are not allowed by Rackspace and performed using local buffer." |
281
|
|
|
); |
282
|
|
|
|
283
|
|
|
//Using local memory/disk as buffer |
284
|
|
|
return parent::copy($bucket, $destination, $name); |
285
|
|
|
} |
286
|
|
|
|
287
|
|
|
try { |
288
|
|
|
$request = $this->buildRequest('PUT', $destination, $name, [ |
289
|
|
|
'X-Copy-From' => '/' . $bucket->getOption('container') . '/' . rawurlencode($name), |
290
|
|
|
'Content-Length' => 0 |
291
|
|
|
]); |
292
|
|
|
|
293
|
|
|
$this->client->send($request); |
294
|
|
|
} catch (ClientException $e) { |
295
|
|
|
if ($e->getCode() == 401) { |
296
|
|
|
$this->reconnect(); |
297
|
|
|
|
298
|
|
|
return $this->copy($bucket, $destination, $name); |
299
|
|
|
} |
300
|
|
|
|
301
|
|
|
throw new ServerException($e->getMessage(), $e->getCode(), $e); |
302
|
|
|
} |
303
|
|
|
|
304
|
|
|
return true; |
305
|
|
|
} |
306
|
|
|
|
307
|
|
|
/** |
308
|
|
|
* Connect to rackspace servers using new or cached token. |
309
|
|
|
* |
310
|
|
|
* @throws ServerException |
311
|
|
|
*/ |
312
|
|
|
protected function connect() |
313
|
|
|
{ |
314
|
|
|
if (!empty($this->authToken)) { |
315
|
|
|
//Already got credentials from cache |
316
|
|
|
return; |
317
|
|
|
} |
318
|
|
|
|
319
|
|
|
$username = $this->options['username']; |
320
|
|
|
$apiKey = $this->options['apiKey']; |
321
|
|
|
|
322
|
|
|
//Credentials request |
323
|
|
|
$request = new Request( |
324
|
|
|
'POST', |
325
|
|
|
$this->options['authServer'], |
326
|
|
|
['Content-Type' => 'application/json'], |
327
|
|
|
json_encode([ |
328
|
|
|
'auth' => ['RAX-KSKEY:apiKeyCredentials' => compact('username', 'apiKey')] |
329
|
|
|
]) |
330
|
|
|
); |
331
|
|
|
|
332
|
|
|
try { |
333
|
|
|
/** |
334
|
|
|
* @var ResponseInterface $response |
335
|
|
|
*/ |
336
|
|
|
$response = $this->client->send($request); |
337
|
|
|
} catch (ClientException $e) { |
338
|
|
|
if ($e->getCode() == 401) { |
339
|
|
|
throw new ServerException( |
340
|
|
|
"Unable to perform RackSpace authorization using given credentials" |
341
|
|
|
); |
342
|
|
|
} |
343
|
|
|
|
344
|
|
|
throw new ServerException($e->getMessage(), $e->getCode(), $e); |
345
|
|
|
} |
346
|
|
|
|
347
|
|
|
$response = json_decode((string)$response->getBody(), 1); |
348
|
|
|
foreach ($response['access']['serviceCatalog'] as $location) { |
349
|
|
|
if ($location['name'] == 'cloudFiles') { |
350
|
|
|
foreach ($location['endpoints'] as $server) { |
351
|
|
|
$this->regions[$server['region']] = $server['publicURL']; |
352
|
|
|
} |
353
|
|
|
} |
354
|
|
|
} |
355
|
|
|
|
356
|
|
|
if (!isset($response['access']['token']['id'])) { |
357
|
|
|
throw new ServerException("Unable to fetch rackspace auth token"); |
358
|
|
|
} |
359
|
|
|
|
360
|
|
|
//We got our authorization token (which will expire in some time) |
361
|
|
|
$this->authToken = $response['access']['token']['id']; |
362
|
|
|
|
363
|
|
|
if (!empty($this->cache) && $this->options['cache']) { |
364
|
|
|
$this->cache->set( |
365
|
|
|
$username . '@rackspace-token', |
366
|
|
|
$this->authToken, |
367
|
|
|
$this->options['lifetime'] |
368
|
|
|
); |
369
|
|
|
|
370
|
|
|
$this->cache->set( |
371
|
|
|
$username . '@rackspace-regions', |
372
|
|
|
$this->regions, |
373
|
|
|
$this->options['lifetime'] |
374
|
|
|
); |
375
|
|
|
} |
376
|
|
|
} |
377
|
|
|
|
378
|
|
|
/** |
379
|
|
|
* Reconnect. |
380
|
|
|
* |
381
|
|
|
* @throws ServerException |
382
|
|
|
*/ |
383
|
|
|
protected function reconnect() |
384
|
|
|
{ |
385
|
|
|
$this->authToken = null; |
386
|
|
|
$this->connect(); |
387
|
|
|
} |
388
|
|
|
|
389
|
|
|
/** |
390
|
|
|
* Create instance of UriInterface based on provided bucket options and storage object name. |
391
|
|
|
* |
392
|
|
|
* @param BucketInterface $bucket |
393
|
|
|
* @param string $name |
394
|
|
|
* |
395
|
|
|
* @return UriInterface |
396
|
|
|
* @throws ServerException |
397
|
|
|
*/ |
398
|
|
|
protected function buildUri(BucketInterface $bucket, string $name): UriInterface |
399
|
|
|
{ |
400
|
|
|
if (empty($bucket->getOption('region'))) { |
401
|
|
|
throw new ServerException("Every RackSpace container should have specified region"); |
402
|
|
|
} |
403
|
|
|
|
404
|
|
|
$region = $bucket->getOption('region'); |
405
|
|
|
if (!isset($this->regions[$region])) { |
406
|
|
|
throw new ServerException("'{$region}' region is not supported by RackSpace"); |
407
|
|
|
} |
408
|
|
|
|
409
|
|
|
return new Uri( |
410
|
|
|
$this->regions[$region] . '/' . $bucket->getOption('container') . '/' . rawurlencode($name) |
411
|
|
|
); |
412
|
|
|
} |
413
|
|
|
|
414
|
|
|
/** |
415
|
|
|
* Create pre-configured object request. |
416
|
|
|
* |
417
|
|
|
* @param string $method |
418
|
|
|
* @param BucketInterface $bucket |
419
|
|
|
* @param string $name |
420
|
|
|
* @param array $headers |
421
|
|
|
* |
422
|
|
|
* @return RequestInterface |
423
|
|
|
*/ |
424
|
|
|
protected function buildRequest( |
425
|
|
|
string $method, |
426
|
|
|
BucketInterface $bucket, |
427
|
|
|
string $name, |
428
|
|
|
array $headers = [] |
429
|
|
|
): RequestInterface { |
430
|
|
|
//Request with added auth headers |
431
|
|
|
return new Request( |
432
|
|
|
$method, |
433
|
|
|
$this->buildUri($bucket, $name), |
434
|
|
|
$headers + [ |
435
|
|
|
'X-Auth-Token' => $this->authToken, |
436
|
|
|
'Date' => gmdate('D, d M Y H:i:s T') |
437
|
|
|
] |
438
|
|
|
); |
439
|
|
|
} |
440
|
|
|
} |