Issues (116)

Security Analysis    not enabled

This project does not seem to handle request data directly as such no vulnerable execution paths were found.

  Cross-Site Scripting
Cross-Site Scripting enables an attacker to inject code into the response of a web-request that is viewed by other users. It can for example be used to bypass access controls, or even to take over other users' accounts.
  File Exposure
File Exposure allows an attacker to gain access to local files that he should not be able to access. These files can for example include database credentials, or other configuration files.
  File Manipulation
File Manipulation enables an attacker to write custom data to files. This potentially leads to injection of arbitrary code on the server.
  Object Injection
Object Injection enables an attacker to inject an object into PHP code, and can lead to arbitrary code execution, file exposure, or file manipulation attacks.
  Code Injection
Code Injection enables an attacker to execute arbitrary code on the server.
  Response Splitting
Response Splitting can be used to send arbitrary responses.
  File Inclusion
File Inclusion enables an attacker to inject custom files into PHP's file loading mechanism, either explicitly passed to include, or for example via PHP's auto-loading mechanism.
  Command Injection
Command Injection enables an attacker to inject a shell command that is execute with the privileges of the web-server. This can be used to expose sensitive data, or gain access of your server.
  SQL Injection
SQL Injection enables an attacker to execute arbitrary SQL code on your database server gaining access to user data, or manipulating user data.
  XPath Injection
XPath Injection enables an attacker to modify the parts of XML document that are read. If that XML document is for example used for authentication, this can lead to further vulnerabilities similar to SQL Injection.
  LDAP Injection
LDAP Injection enables an attacker to inject LDAP statements potentially granting permission to run unauthorized queries, or modify content inside the LDAP tree.
  Header Injection
  Other Vulnerability
This category comprises other attack vectors such as manipulating the PHP runtime, loading custom extensions, freezing the runtime, or similar.
  Regex Injection
Regex Injection enables an attacker to execute arbitrary code in your PHP process.
  XML Injection
XML Injection enables an attacker to read files on your local filesystem including configuration files, or can be abused to freeze your web-server process.
  Variable Injection
Variable Injection enables an attacker to overwrite program variables with custom data, and can lead to further vulnerabilities.
Unfortunately, the security analysis is currently not available for your project. If you are a non-commercial open-source project, please contact support to gain access.

src/Client.php (3 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
3
namespace Scriptotek\Alma;
4
5
use Danmichaelo\QuiteSimpleXMLElement\QuiteSimpleXMLElement;
6
use Http\Client\Common\Plugin\ContentLengthPlugin;
7
use Http\Client\Common\Plugin\ErrorPlugin;
8
use Http\Client\Common\PluginClient;
9
use Http\Client\Exception\HttpException;
10
use Http\Client\Exception\NetworkException;
11
use Http\Factory\Discovery\HttpClient;
12
use Http\Factory\Discovery\HttpFactory;
13
use Http\Message\UriFactory;
14
use Psr\Http\Client\ClientInterface as HttpClientInterface;
15
use Psr\Http\Message\RequestFactoryInterface;
16
use Psr\Http\Message\RequestInterface;
17
use Psr\Http\Message\ResponseInterface;
18
use Psr\Http\Message\UriFactoryInterface;
19
use Psr\Http\Message\UriInterface;
20
use Scriptotek\Alma\Analytics\Analytics;
21
use Scriptotek\Alma\Bibs\Bibs;
22
use Scriptotek\Alma\Bibs\Items;
23
use Scriptotek\Alma\Conf\Conf;
24
use Scriptotek\Alma\Conf\Jobs;
25
use Scriptotek\Alma\Conf\Libraries;
26
use Scriptotek\Alma\Conf\Library;
27
use Scriptotek\Alma\Exception\ClientException as AlmaClientException;
28
use Scriptotek\Alma\Exception\InvalidApiKey;
29
use Scriptotek\Alma\Exception\MaxNumberOfAttemptsExhausted;
30
use Scriptotek\Alma\Exception\RequestFailed;
31
use Scriptotek\Alma\Exception\ResourceNotFound;
32
use Scriptotek\Alma\Exception\SruClientNotSetException;
33
use Scriptotek\Alma\TaskLists\LendingRequests;
34
use Scriptotek\Alma\TaskLists\TaskLists;
35
use Scriptotek\Alma\Users\Users;
36
use Scriptotek\Sru\Client as SruClient;
37
use function GuzzleHttp\Psr7\stream_for;
38
39
/**
40
 * Alma client.
41
 */
42
class Client
43
{
44
    public $entrypoint;
45
46
    /** @var string Alma zone (institution or network) */
47
    public $zone;
48
49
    /** @var string Alma Developers Network API key for this zone */
50
    public $key;
51
52
    /** @var Client Network zone instance */
53
    public $nz;
54
55
    /** @var HttpClientInterface */
56
    protected $http;
57
58
    /** @var RequestFactoryInterface */
59
    protected $requestFactory;
60
61
    /** @var UriFactory */
62
    protected $uriFactory;
63
64
    /** @var SruClient */
65
    public $sru;
66
67
    /** @var Bibs */
68
    public $bibs;
69
70
    /** @var Analytics */
71
    public $analytics;
72
73
    /** @var Users */
74
    public $users;
75
76
    /** @var Items */
77
    public $items;
78
79
    /** @var int Max number of retries if we get 429 errors */
80
    public $maxAttempts = 10;
81
82
    /** @var int Max number of retries if we get 5XX errors */
83
    public $maxAttemptsOnServerError = 1;
84
85
    /** @var float Number of seconds to sleep before retrying */
86
    public $sleepTimeOnRetry = 0.5;
87
88
    /** @var float Number of seconds to sleep before retrying after a server error */
89
    public $sleepTimeOnServerError = 10;
90
91
    /** @var array Extra request headers */
92
    public $headers = [];
93
94
    /**
95
     * @var Conf
96
     */
97
    public $conf;
98
99
    /**
100
     * @var Libraries
101
     */
102
    public $libraries;
103
104
    /**
105
     * @var Jobs
106
     */
107
    public $jobs;
108
109
    /**
110
     * @var TaskLists
111
     */
112
    public $taskLists;
113
114
    /**
115
     * Create a new client to connect to a given Alma instance.
116
     *
117
     * @param ?string                  $key            API key
118
     * @param string                   $region         Hosted region code, used to build the entrypoint URL
119
     * @param string                   $zone           Alma zone (Either Zones::INSTITUTION or Zones::NETWORK)
120
     * @param ?HttpClientInterface     $http
121
     * @param ?RequestFactoryInterface $requestFactory
122
     * @param ?UriFactoryInterface     $uriFactory
123
     *
124
     * @throws \ErrorException
125
     */
126
    public function __construct(
127
        $key = null,
128
        $region = 'eu',
129
        $zone = Zones::INSTITUTION,
130
        HttpClientInterface $http = null,
131
        RequestFactoryInterface $requestFactory = null,
132
        UriFactoryInterface $uriFactory = null
133
    ) {
134
        $this->http = new PluginClient(
135
            $http ?: HttpClient::client(),
136
            [
137
                new ContentLengthPlugin(),
138
                new ErrorPlugin(),
139
            ]
140
        );
141
        $this->requestFactory = $requestFactory ?: HttpFactory::requestFactory();
142
        $this->uriFactory = $uriFactory ?: HttpFactory::uriFactory();
0 ignored issues
show
Documentation Bug introduced by
It seems like $uriFactory ?: \Http\Fac...tpFactory::uriFactory() of type object<Psr\Http\Message\UriFactoryInterface> is incompatible with the declared type object<Http\Message\UriFactory> of property $uriFactory.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
143
144
        $this->key = $key;
145
146
        if (!is_null($region)) {
147
            $this->setRegion($region);
148
        }
149
150
        $this->zone = $zone;
151
152
        $this->bibs = new Bibs($this);
153
        $this->items = new Items($this); // Only needed for the fromBarcode method :/
154
155
        $this->analytics = new Analytics($this);
156
        $this->users = new Users($this);
157
158
        $this->conf = new Conf($this);
159
        $this->libraries = $this->conf->libraries;  // shortcut
0 ignored issues
show
The property libraries does not seem to exist in Scriptotek\Alma\Conf\Conf.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
160
        $this->jobs = $this->conf->jobs;  // shortcut
0 ignored issues
show
The property jobs does not seem to exist in Scriptotek\Alma\Conf\Conf.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
161
162
        $this->taskLists = new TaskLists($this);
163
164
        if ($zone == Zones::INSTITUTION) {
165
            $this->nz = new self(
166
                null,
167
                $region,
168
                Zones::NETWORK,
169
                $this->http,
170
                $this->requestFactory,
171
                $this->uriFactory
172
            );
173
        } elseif ($zone != Zones::NETWORK) {
174
            throw new AlmaClientException('Invalid zone name.');
175
        }
176
    }
177
178
    public function lendingRequests(Library $library, $params = [])
179
    {
180
        return new LendingRequests($this, $library, $params);
181
    }
182
183
    /**
184
     * Attach an SRU client (so you can search for Bib records).
185
     *
186
     * @param SruClient $sru
187
     */
188
    public function setSruClient(SruClient $sru)
189
    {
190
        $this->sru = $sru;
191
    }
192
193
    /**
194
     * Assert that an SRU client is connected. Throws SruClientNotSetException if not.
195
     *
196
     * @throws SruClientNotSetException
197
     */
198
    public function assertHasSruClient()
199
    {
200
        if (!isset($this->sru)) {
201
            throw new SruClientNotSetException();
202
        }
203
    }
204
205
    /**
206
     * Set the API key for this Alma instance.
207
     *
208
     * @param string $key The API key
209
     *
210
     * @return $this
211
     */
212
    public function setKey($key)
213
    {
214
        $this->key = $key;
215
216
        return $this;
217
    }
218
219
    /**
220
     * Set the Alma region code ('na' for North America, 'eu' for Europe, 'ap' for Asia Pacific).
221
     *
222
     * @param $regionCode
223
     * @throws AlmaClientException
224
     * @return $this
225
     */
226
    public function setRegion($regionCode)
227
    {
228
        if (!in_array($regionCode, ['na', 'eu', 'ap'])) {
229
            throw new AlmaClientException('Invalid region code');
230
        }
231
        $this->setEntryPoint('https://api-' . $regionCode . '.hosted.exlibrisgroup.com/almaws/v1');
232
233
        return $this;
234
    }
235
236
    /**
237
     * Set the Alma API base url.
238
     *
239
     * @param string $url
240
     * @return $this
241
     */
242
    public function setEntryPoint(string $url)
243
    {
244
        $this->entrypoint = $url;
245
246
        return $this;
247
    }
248
249
    /**
250
     * Set extra request headers.
251
     *
252
     * @param array $headers
253
     * @return $this
254
     */
255
    public function setExtraHeaders(array $headers)
256
    {
257
        $this->headers = $headers;
258
259
        return $this;
260
    }
261
262
    /**
263
     * Extend an URL with query string parameters and return an UriInterface object.
264
     *
265
     * @param string $url
266
     * @param array  $query
267
     *
268
     * @return UriInterface
269
     */
270
    public function buildUrl($url, $query = [])
271
    {
272
        $url = explode('?', $url, 2);
273
        if (count($url) == 2) {
274
            parse_str($url[1], $query0);
275
            $query = array_merge($query0, $query);
276
        }
277
278
        $url = $url[0];
279
280
        if (strpos($url, $this->entrypoint) === false) {
281
            $url = $this->entrypoint . $url;
282
        }
283
284
        return $this->uriFactory->createUri($url)
285
            ->withQuery(http_build_query($query));
286
    }
287
288
    /**
289
     * Make a synchronous HTTP request and return a PSR7 response if successful.
290
     * In the case of intermittent errors (connection problem, 429 or 5XX error), the request is
291
     * attempted a maximum of {$this->maxAttempts} times with a sleep of {$this->sleepTimeOnRetry}
292
     * between each attempt to avoid hammering the server.
293
     *
294
     * @param RequestInterface $request
295
     * @param int              $attempt
296
     *
297
     * @return ResponseInterface
298
     */
299
    public function request(RequestInterface $request, $attempt = 1)
300
    {
301
        if (isset($this->key)) {
302
            $request = $request->withHeader('Authorization', 'apikey ' . $this->key);
303
        }
304
        foreach ($this->headers as $key => $val) {
305
            $request = $request->withHeader($key, $val);
306
        }
307
308
        try {
309
            return $this->http->sendRequest($request);
310
        } catch (HttpException $e) {
311
            // Thrown for 400 and 500 level errors.
312
            $statusCode = $e->getResponse()->getStatusCode();
313
314
            $error = $this->parseClientError($e);
315
316
            if ($error->getErrorCode() === 'PER_SECOND_THRESHOLD') {
317
                // We've run into the "Max 25 API calls per institution per second" limit.
318
                // Wait a sec and retry, unless we've tried too many times already.
319
                if ($attempt >= $this->maxAttempts) {
320
                    throw new MaxNumberOfAttemptsExhausted(
321
                        'Rate limiting error - max number of retry attempts exhausted.',
322
                        0,
323
                        $e
324
                    );
325
                }
326
                time_nanosleep(0, $this->sleepTimeOnRetry * 1000000000);
327
328
                return $this->request($request, $attempt + 1);
329
            }
330
331
            if ($statusCode >= 500 && $statusCode < 600) {
332
                if ($attempt >= $this->maxAttemptsOnServerError) {
333
                    throw $error;
334
                }
335
                time_nanosleep(0, $this->sleepTimeOnServerError * 1000000000);
336
337
                return $this->request($request, $attempt + 1);
338
            }
339
340
            // Throw exception for other errors
341
            throw $error;
342
        } catch (NetworkException $e) {
343
            // Thrown in case of a networking error
344
            // Wait a sec and retry, unless we've tried too many times already.
345
            if ($attempt > $this->maxAttempts) {
346
                throw new MaxNumberOfAttemptsExhausted(
347
                    'Network error - max number of retry attempts exhausted.',
348
                    0,
349
                    $e
350
                );
351
            }
352
            time_nanosleep(0, $this->sleepTimeOnRetry * 1000000000);
353
354
            return $this->request($request, $attempt + 1);
355
        }
356
    }
357
358
    /**
359
     * Make a GET request.
360
     *
361
     * @param string $url
362
     * @param array  $query
363
     * @param string $contentType
364
     *
365
     * @return string The response body
366
     */
367
    public function get($url, $query = [], $contentType = 'application/json')
368
    {
369
        $url = $this->buildUrl($url, $query);
370
        $request = $this->requestFactory->createRequest('GET', $url)
371
            ->withHeader('Accept', $contentType);
372
373
        $response = $this->request($request);
374
375
        return strval($response->getBody());
376
    }
377
378
    /**
379
     * Make a GET request, accepting JSON.
380
     *
381
     * @param string $url
382
     * @param array  $query
383
     *
384
     * @return \stdClass JSON response as an object.
385
     */
386
    public function getJSON($url, $query = [])
387
    {
388
        $responseBody = $this->get($url, $query, 'application/json');
389
390
        return json_decode($responseBody);
391
    }
392
393
    /**
394
     * Make a GET request, accepting XML.
395
     *
396
     * @param string $url
397
     * @param array  $query
398
     *
399
     * @return QuiteSimpleXMLElement
400
     */
401
    public function getXML($url, $query = [])
402
    {
403
        $responseBody = $this->get($url, $query, 'application/xml');
404
405
        return new QuiteSimpleXMLElement($responseBody);
406
    }
407
408
    /**
409
     * Make a PUT request.
410
     *
411
     * @param string $url
412
     * @param $data
413
     * @param string $contentType
414
     *
415
     * @return string The response body
416
     */
417 View Code Duplication
    public function put($url, $data, $contentType = 'application/json')
418
    {
419
        $uri = $this->buildUrl($url);
420
421
        $request = $this->requestFactory->createRequest('PUT', $uri);
422
        if (!is_null($contentType)) {
423
            $request = $request->withHeader('Content-Type', $contentType);
424
            $request = $request->withHeader('Accept', $contentType);
425
        }
426
        $request = $request->withBody(stream_for($data));
427
428
        $response = $this->request($request);
429
430
        return strval($response->getBody());
431
    }
432
433
    /**
434
     * Make a PUT request, sending JSON data.
435
     *
436
     * @param string $url
437
     * @param $data
438
     *
439
     * @return \stdClass
440
     */
441
    public function putJSON($url, $data)
442
    {
443
        $responseBody = $this->put($url, json_encode($data), 'application/json');
444
445
        return json_decode($responseBody);
446
    }
447
448
    /**
449
     * Make a PUT request, sending XML data.
450
     *
451
     * @param string $url
452
     * @param $data
453
     *
454
     * @return QuiteSimpleXMLElement
455
     */
456
    public function putXML($url, $data)
457
    {
458
        $responseBody = $this->put($url, $data, 'application/xml');
459
460
        return new QuiteSimpleXMLElement($responseBody);
461
    }
462
463
    /**
464
     * Make a POST request.
465
     *
466
     * @param string $url
467
     * @param $data
468
     * @param string $contentType
469
     *
470
     * @return string The response body
471
     */
472 View Code Duplication
    public function post($url, $data, $contentType = 'application/json')
473
    {
474
        $uri = $this->buildUrl($url);
475
476
        $request = $this->requestFactory->createRequest('POST', $uri);
477
        if (!is_null($contentType)) {
478
            $request = $request->withHeader('Content-Type', $contentType);
479
            $request = $request->withHeader('Accept', $contentType);
480
        }
481
        $request = $request->withBody(stream_for($data));
482
483
        $response = $this->request($request);
484
485
        return strval($response->getBody());
486
    }
487
488
    /**
489
     * Make a POST request, sending JSON data.
490
     *
491
     * @param string $url
492
     * @param $data
493
     *
494
     * @return \stdClass
495
     */
496
    public function postJSON($url, $data = null)
497
    {
498
        $responseBody = $this->post($url, json_encode($data), 'application/json');
499
500
        return json_decode($responseBody);
501
    }
502
503
    /**
504
     * Make a POST request, sending XML data.
505
     *
506
     * @param string $url
507
     * @param $data
508
     *
509
     * @return QuiteSimpleXMLElement
510
     */
511
    public function postXML($url, $data = null)
512
    {
513
        $responseBody = $this->post($url, $data, 'application/xml');
514
515
        return new QuiteSimpleXMLElement($responseBody);
516
    }
517
518
    /**
519
     * Get the redirect target location of an URL, or null if not a redirect.
520
     *
521
     * @param string $url
522
     * @param array  $query
523
     *
524
     * @return string|null
525
     */
526
    public function getRedirectLocation($url, $query = [])
527
    {
528
        $url = $this->buildUrl($url, $query);
529
530
        $request = $this->requestFactory->createRequest('GET', $url);
531
532
        try {
533
            $response = $this->request($request);
534
        } catch (ResourceNotFound $e) {
535
            return;
536
        }
537
538
        $locations = $response->getHeader('Location');
539
540
        return count($locations) ? $locations[0] : null;
541
    }
542
543
    /**
544
     * @param class $className
545
     * @param array ...$params
546
     *
547
     * @return mixed
548
     */
549
    public function make($className, ...$params)
550
    {
551
        return new $className($this, ...$params);
552
    }
553
554
    /**
555
     * Generate a client exception.
556
     *
557
     * @param HttpException $exception
558
     *
559
     * @return RequestFailed
560
     */
561
    protected function parseClientError(HttpException $exception)
562
    {
563
        $contentType = explode(';', $exception->getResponse()->getHeaderLine('Content-Type'))[0];
564
        $responseBody = (string) $exception->getResponse()->getBody();
565
566
        switch ($contentType) {
567
            case 'application/json':
568
                $res = json_decode($responseBody, true);
569
                if (isset($res['web_service_result'])) {
570
                    $res = $res['web_service_result'];
571
                }
572
                $err = isset($res['errorList']['error'][0]) ? $res['errorList']['error'][0] : $res['errorList']['error'];
573
                $message = $err['errorMessage'];
574
                $code = $err['errorCode'];
575
                break;
576
577
            case 'application/xml':
578
                $xml = new QuiteSimpleXMLElement($responseBody);
579
                $xml->registerXPathNamespace('xb', 'http://com/exlibris/urm/general/xmlbeans');
580
581
                $message = $xml->text('//xb:errorMessage');
582
                $code = $xml->text('//xb:errorCode');
583
                break;
584
585
            default:
586
                $message = $responseBody;
587
                $code = '';
588
        }
589
590
        // The error code is often an integer, but sometimes a string,
591
        // so we generalize it as a string.
592
        $code = empty($code) ? null : (string) $code;
593
594
        if ($code == 'UNAUTHORIZED') {
595
            return new InvalidApiKey($message, null, $exception);
596
        }
597
598
        if (strtolower($message) == 'invalid api key') {
599
            return new InvalidApiKey($message, null, $exception);
600
        }
601
602
        if (preg_match('/(no items?|not) found/i', $message)) {
603
            return new ResourceNotFound($message, $code, $exception);
604
        }
605
606
        return new RequestFailed($message, $code, $exception);
607
    }
608
}
609