Completed
Push — master ( 3ac5c4...483436 )
by Anton
03:47
created

RackspaceServer::delete()   C

Complexity

Conditions 7
Paths 6

Size

Total Lines 23
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 14
nc 6
nop 3
dl 0
loc 23
rs 6.7272
c 0
b 0
f 0
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
}