Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 887
0.00% covered (danger)
0.00%
0 / 37
CRAP
0.00% covered (danger)
0.00%
0 / 1
OverdriveConnector
0.00% covered (danger)
0.00%
0 / 887
0.00% covered (danger)
0.00%
0 / 37
46440
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getSessionContainer
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 getUser
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getAccess
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
56
 getAvailability
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
110
 getAvailabilityBulk
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
272
 getCollectionToken
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
72
 doOverdriveCheckout
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
56
 placeOverDriveHold
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
42
 updateOverDriveHold
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 suspendHold
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
56
 editSuspendedHold
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
42
 deleteHoldSuspension
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 cancelHold
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
30
 returnResource
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 getDownloadRedirect
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
42
 getAuthHeader
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
6
 getConfig
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
12
 getFormatNames
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 getPermanentLinks
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 getMagazineIssues
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
42
 getMetadata
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 getMetadataForTitles
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 getCheckout
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 getHold
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getCheckouts
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
110
 getHolds
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
132
 callUrl
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
132
 connectToAPI
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
90
 callPatronUrl
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 1
240
 connectToPatronAPI
0.00% covered (danger)
0.00%
0 / 60
0.00% covered (danger)
0.00%
0 / 1
132
 getHttpClient
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 setCacheStorage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCachedData
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 putCachedData
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 removeCachedData
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getResultObject
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * PHP version 8
5 *
6 * Copyright (C) Villanova University 2018.
7 *
8 * This program is free software; you can redistribute it and/or modify
9 * it under the terms of the GNU General Public License version 2,
10 * as published by the Free Software Foundation.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 * GNU General Public License for more details.
16 *
17 * You should have received a copy of the GNU General Public License
18 * along with this program; if not, write to the Free Software
19 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301
20 * USA
21 *
22 * @category VuFind
23 * @package  DigitalContent
24 * @author   Demian Katz <demian.katz@villanova.edu>
25 * @author   Brent Palmer <brent-palmer@icpl.org>
26 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public
27 *           License
28 * @link     https://vufind.org/wiki/development Wiki
29 */
30
31namespace VuFind\DigitalContent;
32
33use Exception;
34use Laminas\Cache\Storage\StorageInterface;
35use Laminas\Config\Config;
36use Laminas\Http\Client;
37use Laminas\Log\LoggerAwareInterface;
38use Laminas\Session\Container;
39use LmcRbacMvc\Service\AuthorizationServiceAwareInterface;
40use LmcRbacMvc\Service\AuthorizationServiceAwareTrait;
41use VuFind\Auth\ILSAuthenticator;
42use VuFind\Cache\KeyGeneratorTrait;
43use VuFind\Exception\ILS as ILSException;
44
45use function count;
46
47/**
48 * OverdriveConnector
49 *
50 * Class responsible for connecting to the OverDrive API
51 *
52 * @category VuFind
53 * @package  DigitalContent
54 * @author   Demian Katz <demian.katz@villanova.edu>
55 * @author   Brent Palmer <brent-palmer@icpl.org>
56 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public
57 *           License
58 * @link     https://vufind.org/wiki/development Wiki
59 * @todo     provide option for autocheckout by default in config
60 *       allow override for cover display using other covers
61 *       provide option for not requiring email for holds
62 *       provide option for giving users option for every hold
63 *       provide option for asking about autocheckout for every hold
64 *       provide config options for how to handle patrons with no access to OD
65 */
66class OverdriveConnector implements
67    LoggerAwareInterface,
68    AuthorizationServiceAwareInterface,
69    \VuFindHttp\HttpServiceAwareInterface
70{
71    use \VuFind\Log\LoggerAwareTrait {
72        logError as error;
73    }
74    use AuthorizationServiceAwareTrait;
75    use \VuFindHttp\HttpServiceAwareTrait;
76    use KeyGeneratorTrait;
77
78    /**
79     * Session Container
80     *
81     * @var Container
82     */
83    protected $sessionContainer;
84
85    /**
86     * OverDrive-specific configuration
87     *
88     * @var Config
89     */
90    protected $recordConfig;
91
92    /**
93     * Main VuFind configuration
94     *
95     * @var Config
96     */
97    protected $mainConfig;
98
99    /**
100     * ILS Authorization
101     *
102     * @var ILSAuthenticator
103     */
104    protected $ilsAuth;
105
106    /**
107     * HTTP Client
108     *
109     * Client for making calls to the API
110     *
111     * @var Client
112     */
113    protected $client;
114
115    /**
116     * Cache for storing ILS data temporarily (e.g. patron blocks)
117     *
118     * @var StorageInterface
119     */
120    protected $cache = null;
121
122    /**
123     * Constructor
124     *
125     * @param Config           $mainConfig       VuFind main conf
126     * @param Config           $recordConfig     Record-specific conf file
127     * @param ILSAuthenticator $ilsAuth          ILS Authenticator
128     * @param Container        $sessionContainer container
129     */
130    public function __construct(
131        Config $mainConfig,
132        Config $recordConfig,
133        ILSAuthenticator $ilsAuth,
134        Container $sessionContainer = null
135    ) {
136        $this->mainConfig = $mainConfig;
137        $this->recordConfig = $recordConfig;
138        $this->ilsAuth = $ilsAuth;
139        $this->sessionContainer = $sessionContainer;
140    }
141
142    /**
143     * Loads the session container
144     *
145     * @return \Laminas\Session\Container
146     */
147    protected function getSessionContainer()
148    {
149        if (null === $this->sessionContainer || !$this->sessionContainer) {
150            error_log('NO SESSION CONTAINER');
151        }
152        return $this->sessionContainer;
153    }
154
155    /**
156     * Get (Logged-in) User
157     *
158     * Returns the currently logged in user or false if the user is not
159     *
160     * @return array|boolean  an array of user info from the ILSAuthenticator
161     *                        or false if user is not logged in.
162     */
163    public function getUser()
164    {
165        try {
166            $user = $this->ilsAuth->storedCatalogLogin();
167        } catch (ILSException $e) {
168            return false;
169        }
170        return $user;
171    }
172
173    /**
174     * Get OverDrive Access
175     *
176     * Whether the patron has access to overdrive actions (hold,
177     * checkout etc.).
178     * This is stored and retrieved from the session.
179     *
180     * @param bool $refresh Force a check instead of checking cache
181     *
182     * @return object
183     */
184    public function getAccess($refresh = false)
185    {
186        if (!$user = $this->getUser()) {
187            return $this->getResultObject(false, 'User not logged in.');
188        }
189
190        $odAccess = $this->getSessionContainer()->odAccess;
191        if ($refresh || empty($odAccess)) {
192            if (
193                $this->connectToPatronAPI(
194                    $user['cat_username'],
195                    $user['cat_password'],
196                    true
197                )
198            ) {
199                $result = $this->getSessionContainer()->odAccess
200                    = $this->getResultObject(true);
201            } else {
202                $result = $this->getResultObject();
203                // There is some problem with the account
204                $result->code = 'od_account_problem';
205                $conf = $this->getConfig();
206
207                if ($conf->noAccessString) {
208                    if (
209                        str_contains(
210                            $this->getSessionContainer()->odAccessMessage,
211                            $conf->noAccessString
212                        )
213                    ) {
214                        // This user should not have access to OD
215                        $result->code = 'od_account_noaccess';
216                    }
217                }
218                // odAccessMessage is set in the session by the API call above
219                // maybe it should be saved to a class property instead
220                $result->msg = $this->getSessionContainer()->odAccessMessage;
221                $this->getSessionContainer()->odAccess = $result;
222            }
223        } else {
224            $result = $this->getSessionContainer()->odAccess;
225        }
226
227        return $result;
228    }
229
230    /**
231     * Get Availability
232     *
233     * Retrieves the availability for a single resource from OverDrive API
234     * with information like copiesOwned, copiesAvailable, numberOfHolds et.
235     *
236     * @param string $overDriveId The OverDrive ID (reserve ID) of the eResource
237     *
238     * @return object  Standard object with availability info
239     *
240     * @link https://developer.overdrive.com/apis/library-availability-new
241     */
242    public function getAvailability($overDriveId)
243    {
244        $result = $this->getResultObject();
245        if (!$overDriveId) {
246            $this->logWarning('no overdrive content ID was passed in.');
247            return $result;
248        }
249
250        if ($conf = $this->getConfig()) {
251            $collectionToken = $this->getCollectionToken();
252            // Hmm, no token. If user is logged in let's check access
253            if (!$collectionToken && $this->getUser()) {
254                $accessResult = $this->getAccess();
255                if (!$accessResult->status) {
256                    return $accessResult;
257                }
258            }
259            $baseUrl = $conf->discURL;
260            $availabilityUrl
261                = "$baseUrl/v2/collections/$collectionToken/products/";
262            $availabilityUrl .= "$overDriveId/availability";
263            $res = $this->callUrl($availabilityUrl);
264
265            if (($res->errorCode ?? '') == 'NotFound') {
266                if ($conf->consortiumSupport && !$this->getUser()) {
267                    // Consortium support is turned on but user is not logged in;
268                    // if the title is not found it probably means that it's only
269                    // available to some users.
270                    $result->status = true;
271                    $result->code = 'od_code_login_for_avail';
272                } else {
273                    $result->status = false;
274                    $result->code = 'od_code_resource_not_found';
275                    $this->logWarning("resource not found: $overDriveId");
276                }
277            } else {
278                $result->status = true;
279                if ($res) {
280                    $res->copiesAvailable ??= 0;
281                    $res->copiesOwned ??= 0;
282                    $res->numberOfHolds ??= 0;
283                    $res->code = 'od_none';
284                }
285                $result->data = $res;
286            }
287        }
288
289        return $result;
290    }
291
292    /**
293     * Get Availability (in) Bulk
294     *
295     * Gets availability for up to 25 titles at once. This is used by the
296     * the ajax availability system
297     *
298     * @param array $overDriveIds The OverDrive ID (reserve IDs) of the
299     *                            eResources
300     *
301     * @return object|bool see getAvailability
302     *
303     * @todo if more than 25 passed in, make multiple calls
304     */
305    public function getAvailabilityBulk($overDriveIds = [])
306    {
307        $result = $this->getResultObject();
308        $loginRequired = false;
309        if (count($overDriveIds) < 1) {
310            $this->logWarning('no overdrive content ID was passed in.');
311            return false;
312        }
313
314        if ($conf = $this->getConfig()) {
315            if ($conf->consortiumSupport && !$this->getUser()) {
316                $loginRequired = true;
317            }
318            $collectionToken = $this->getCollectionToken();
319            // Hmm, no token. If user is logged in let's check access
320            if (!$collectionToken && $this->getUser()) {
321                $accessResult = $this->getAccess();
322                if (!$accessResult->status) {
323                    return $accessResult;
324                }
325            }
326            $baseUrl = $conf->discURL;
327            $availabilityPath = '/v2/collections/';
328            $availabilityPath .= "$collectionToken/availability?products=";
329            $availabilityUrl = $baseUrl . $availabilityPath .
330                implode(',', $overDriveIds);
331            $res = $this->callUrl($availabilityUrl);
332            if (!$res) {
333                $result->code = 'od_code_connection_failed';
334            } else {
335                if (($res->errorCode ?? null) == 'NotFound' || $res->totalItems == 0) {
336                    if ($loginRequired) {
337                        // Consortium support is turned on but user is not logged in.
338                        // If the title is not found it could mean that it's only
339                        // available to some users.
340                        $result->status = true;
341                        $result->code = 'od_code_login_for_avail';
342                    } else {
343                        $result->status = false;
344                        $result->code = 'od_code_resource_not_found';
345                        $this->logWarning('resources not found');
346                    }
347                } else {
348                    $result->status = true;
349                    foreach ($res->availability as $item) {
350                        $item->copiesAvailable ??= 0;
351                        $item->copiesOwned ??= 0;
352                        $item->numberOfHolds ??= 0;
353                        $result->data[strtolower($item->reserveId)] = $item;
354                        $res->code = 'od_none';
355                    }
356                    // Now look for items not returned
357                    foreach ($overDriveIds as $id) {
358                        if (!isset($result->data[$id])) {
359                            if ($loginRequired) {
360                                $result->data[$id] = $this->getResultObject(
361                                    $status = false,
362                                    $msg = '',
363                                    $code = 'od_code_login_for_avail'
364                                );
365                            } else {
366                                $result->data[$id] = $this->getResultObject(
367                                    $status = false,
368                                    $msg = '',
369                                    $code = 'od_code_resource_not_found'
370                                );
371                            }
372                        }
373                    }
374                }
375            }
376        }
377        return $result;
378    }
379
380    /**
381     * Get Collection Token
382     *
383     * Gets the collection token for the OverDrive collection. The collection
384     * token doesn't change much but according to
385     * the OD API docs it could change and should be retrieved each session.
386     * Also, the collection token depends on the user if the user is in a
387     * consortium. If consortium support is turned on then the user collection
388     * token will override the library collection token.
389     * The token itself is returned but it's also saved in the session and
390     * automatically returned.
391     *
392     * @return object|bool A collection token for the library's collection.
393     */
394    public function getCollectionToken()
395    {
396        $collectionToken = $this->getCachedData('collectionToken');
397        $userCollectionToken = $this->getSessionContainer(
398        )->userCollectionToken;
399        $conf = $this->getConfig();
400        if ($conf->consortiumSupport && $conf->usePatronAPI && $user = $this->getUser()) {
401            if (empty($userCollectionToken)) {
402                $baseUrl = $conf->circURL;
403                $patronURL = "$baseUrl/v1/patrons/me";
404                $res = $this->callPatronUrl(
405                    $user['cat_username'],
406                    $user['cat_password'],
407                    $patronURL
408                );
409                if ($res) {
410                    $userCollectionToken = $res->collectionToken;
411                    $this->getSessionContainer()->userCollectionToken
412                        = $userCollectionToken;
413                } else {
414                    return false;
415                }
416            }
417            return $userCollectionToken;
418        }
419        if (empty($collectionToken)) {
420            $this->debug('getting new collectionToken');
421            $baseUrl = $conf->discURL;
422            $libraryID = $conf->libraryID;
423            $libraryURL = "$baseUrl/v1/libraries/$libraryID";
424            $res = $this->callUrl($libraryURL);
425            if ($res) {
426                $collectionToken = $res->collectionToken;
427                $this->debug("OD collectionToken: $collectionToken");
428                $this->putCachedData('collectionToken', $collectionToken);
429            } else {
430                return false;
431            }
432        }
433        return $collectionToken;
434    }
435
436    /**
437     * OverDrive Checkout
438     * Processes a request to checkout a title from OverDrive
439     *
440     * @param string $overDriveId The overdrive id for the title
441     *
442     * @return object $result Results of the call.
443     */
444    public function doOverdriveCheckout($overDriveId)
445    {
446        $result = $this->getResultObject();
447
448        $this->debug('OverDriveConnector: doOverdriveCheckout|overdriveID: ' . $overDriveId);
449        if (!$user = $this->getUser()) {
450            $this->error('Checkout - user is not logged in', [], true);
451            return $result;
452        }
453        if ($config = $this->getConfig()) {
454            if (!$config->usePatronAPI) {
455                $this->error('Checkout - OverDrive patron APIs are disabled.');
456                return $result;
457            }
458            $url = $config->circURL . '/v1/patrons/me/checkouts';
459            $params = [
460                'reserveId' => $overDriveId,
461            ];
462
463            $response = $this->callPatronUrl(
464                $user['cat_username'],
465                $user['cat_password'],
466                $url,
467                $params,
468                'POST'
469            );
470
471            if (!empty($response)) {
472                if (isset($response->reserveId)) {
473                    $expires = '';
474                    if ($dt = new \DateTime($response->expires)) {
475                        $expires = $dt->format(
476                            (string)$config->displayDateFormat
477                        );
478                    }
479                    $result->status = true;
480                    $result->data = (object)[];
481                    $result->data->expires = $expires;
482                    $result->data->formats = $response->formats;
483                    // Add the checkout to the session cache
484                    $this->getSessionContainer()->checkouts[] = $response;
485                } else {
486                    $result->msg = $response->message;
487                }
488            } else {
489                $result->code = 'od_code_connection_failed';
490            }
491        }
492        return $result;
493    }
494
495    /**
496     * Places a hold on an item within OverDrive
497     *
498     * @param string $overDriveId The overdrive id for the title
499     * @param string $email       The email overdrive should use for notification
500     *
501     * @return \stdClass Object with result
502     */
503    public function placeOverDriveHold($overDriveId, $email)
504    {
505        $overDriveId = strtoupper($overDriveId);
506        $this->debug('OverDriveConnector: placeOverDriveHold: OverDriveID: $overDriveId, Email: $email');
507        $holdResult = $this->getResultObject();
508
509        if (!$user = $this->getUser()) {
510            $this->error('user is not logged in (hold)', [], true);
511            return $holdResult;
512        }
513
514        if ($config = $this->getConfig()) {
515            if (!$config->usePatronAPI) {
516                $this->error('Place hold - OverDrive patron APIs are disabled.');
517                return $holdResult;
518            }
519            $ignoreHoldEmail = false;
520            $url = $config->circURL . '/v1/patrons/me/holds';
521            $action = 'POST';
522            $params = [
523                'reserveId' => $overDriveId,
524                'emailAddress' => $email,
525                'ignoreHoldEmail' => $ignoreHoldEmail,
526            ];
527
528            $response = $this->callPatronUrl(
529                $user['cat_username'],
530                $user['cat_password'],
531                $url,
532                $params,
533                $action
534            );
535
536            if (!empty($response)) {
537                if (isset($response->holdListPosition)) {
538                    $holdResult->status = true;
539                    $holdResult->data = (object)[];
540                    $holdResult->data->holdListPosition
541                        = $response->holdListPosition;
542                } else {
543                    $holdResult->msg = $response->message;
544                }
545            } else {
546                $holdResult->code = 'od_code_connection_failed';
547            }
548        }
549        return $holdResult;
550    }
551
552    /**
553     * Updates the email address for a hold on an item within OverDrive
554     *
555     * @param string $overDriveId The overdrive id for the title
556     * @param string $email       The email overdrive should use for notif
557     *
558     * @return \stdClass Object with result
559     */
560    public function updateOverDriveHold($overDriveId, $email)
561    {
562        $overDriveId = strtoupper($overDriveId);
563        $this->debug('OverDriveConnector: updateOverDriveHold: OverDriveID: $overDriveId, Email: $email');
564        $holdResult = $this->getResultObject();
565
566        if (!$user = $this->getUser()) {
567            $this->error('user is not logged in (upd hold)', [], true);
568            return $holdResult;
569        }
570
571        if ($config = $this->getConfig()) {
572            if (!$config->usePatronAPI) {
573                $this->error('Update hold - OverDrive patron APIs are disabled.');
574                return $holdResult;
575            }
576            $autoCheckout = true;
577            $ignoreHoldEmail = false;
578            $url = $config->circURL . '/v1/patrons/me/holds/' . $overDriveId;
579            $action = 'PUT';
580            $params = [
581                'reserveId' => $overDriveId,
582                'emailAddress' => $email,
583            ];
584
585            $response = $this->callPatronUrl(
586                $user['cat_username'],
587                $user['cat_password'],
588                $url,
589                $params,
590                $action
591            );
592
593            // because this is a PUT Call, we are just looking for a boolean
594            if ($response) {
595                $holdResult->status = true;
596            } else {
597                $holdResult->msg = $response->message;
598            }
599        }
600        return $holdResult;
601    }
602
603    /**
604     * Suspend Hold
605     * Suspend an existing OverDrive Hold
606     *
607     * @param string $overDriveId    The overdrive id for the title
608     * @param string $email          The email overdrive should use for notif
609     * @param string $suspensionType indefinite or limited
610     * @param int    $numberOfDays   number of days to suspend the hold
611     *
612     * @return \stdClass Object with result
613     */
614    public function suspendHold($overDriveId, $email, $suspensionType = 'indefinite', $numberOfDays = 7)
615    {
616        $holdResult = $this->getResultObject();
617        $this->debug("suspendHold: OverDriveID:$overDriveId|Email:$email|suspensionType:$suspensionType");
618
619        if (!$user = $this->getUser()) {
620            $this->error('user is not logged in (susp hold)', [], true);
621            return $holdResult;
622        }
623        if ($config = $this->getConfig()) {
624            if (!$config->usePatronAPI) {
625                $this->error('Suspend hold - OverDrive patron APIs are disabled.');
626                return $holdResult;
627            }
628            $url = $config->circURL . "/v1/patrons/me/holds/$overDriveId/suspension";
629            $action = 'POST';
630            $params = [
631                'emailAddress' => $email,
632                'suspensionType' => $suspensionType,
633            ];
634            if ($suspensionType == 'limited') {
635                $params['numberOfDays'] = $numberOfDays;
636            }
637
638            $response = $this->callPatronUrl(
639                $user['cat_username'],
640                $user['cat_password'],
641                $url,
642                $params,
643                $action
644            );
645
646            if (!empty($response)) {
647                if (isset($response->holdListPosition)) {
648                    $holdResult->status = true;
649                    $holdResult->data = $response;
650                } else {
651                    $holdResult->msg = $response->message;
652                }
653            } else {
654                $holdResult->code = 'od_code_connection_failed';
655            }
656        }
657        return $holdResult;
658    }
659
660    /**
661     * Edit Suspended Hold
662     * Change the redelivery date on an already suspended hold
663     *
664     * @param string $overDriveId    The overdrive id for the title
665     * @param string $email          The email overdrive should use for notif
666     * @param string $suspensionType indefinite or limited
667     * @param int    $numberOfDays   number of days to suspend the hold
668     *
669     * @return \stdClass Object with result
670     */
671    public function editSuspendedHold($overDriveId, $email, $suspensionType = 'indefinite', $numberOfDays = 7)
672    {
673        $holdResult = $this->getResultObject();
674
675        if (!$user = $this->getUser()) {
676            $this->error('user is not logged in (edit susp hold)', [], true);
677            return $holdResult;
678        }
679        if ($config = $this->getConfig()) {
680            if (!$config->usePatronAPI) {
681                $this->error('Edit suspended hold - OverDrive patron APIs are disabled.');
682                return $holdResult;
683            }
684            $url = $config->circURL . "/v1/patrons/me/holds/$overDriveId/suspension";
685            $action = 'PUT';
686            $params = [
687                'emailAddress' => $email,
688                'suspensionType' => $suspensionType,
689            ];
690            if ($suspensionType == 'limited') {
691                $params['numberOfDays'] = $numberOfDays;
692            }
693
694            $response = $this->callPatronUrl(
695                $user['cat_username'],
696                $user['cat_password'],
697                $url,
698                $params,
699                $action
700            );
701
702            // because this is a PUT Call, we are just looking for a boolean
703            if ($response) {
704                $holdResult->status = true;
705            } else {
706                $holdResult->msg = $response->message;
707            }
708        }
709        return $holdResult;
710    }
711
712    /**
713     * Delete Suspended Hold
714     * Removes the suspension from a hold
715     *
716     * @param string $overDriveId The overdrive id for the title
717     *
718     * @return \stdClass Object with result
719     */
720    public function deleteHoldSuspension($overDriveId)
721    {
722        $holdResult = $this->getResultObject();
723
724        if (!$user = $this->getUser()) {
725            $this->error('user is not logged in (del hold susp)', [], true);
726            return $holdResult;
727        }
728        if ($config = $this->getConfig()) {
729            if (!$config->usePatronAPI) {
730                $this->error('Delete hold suspension - OverDrive patron APIs are disabled.');
731                return $holdResult;
732            }
733            $url = $config->circURL . "/v1/patrons/me/holds/$overDriveId/suspension";
734            $action = 'DELETE';
735            $response = $this->callPatronUrl(
736                $user['cat_username'],
737                $user['cat_password'],
738                $url,
739                null,
740                $action
741            );
742
743            // because this is a DELETE Call, we are just looking for a boolean
744            if ($response) {
745                $holdResult->status = true;
746            } else {
747                $holdResult->msg = $response->message;
748            }
749        }
750        return $holdResult;
751    }
752
753    /**
754     * Cancel Hold
755     * Cancel and existing OverDrive Hold
756     *
757     * @param string $overDriveId The overdrive id for the title
758     *
759     * @return \stdClass Object with result
760     */
761    public function cancelHold($overDriveId)
762    {
763        $holdResult = $this->getResultObject();
764        $this->debug('OverDriveConnector: cancelHold | OverDriveID: $overDriveID');
765        if (!$user = $this->getUser()) {
766            $this->error('user is not logged in (cancel hold)', [], true);
767            return $holdResult;
768        }
769        if ($config = $this->getConfig()) {
770            if (!$config->usePatronAPI) {
771                $this->error('Cancel hold - OverDrive patron APIs are disabled.');
772                return $holdResult;
773            }
774            $url = $config->circURL . "/v1/patrons/me/holds/$overDriveId";
775            $action = 'DELETE';
776            $response = $this->callPatronUrl(
777                $user['cat_username'],
778                $user['cat_password'],
779                $url,
780                null,
781                $action
782            );
783
784            // Because this is a DELETE Call, we are just looking for a boolean
785            if ($response) {
786                $holdResult->status = true;
787            } else {
788                $holdResult->msg = $response->message;
789            }
790        }
791        return $holdResult;
792    }
793
794    /**
795     * Return Resource
796     * Return a title early.
797     *
798     * @param string $resourceID OverDrive ID of the resource
799     *
800     * @return object|bool Object with result
801     */
802    public function returnResource($resourceID)
803    {
804        $result = $this->getResultObject();
805        if (!$user = $this->getUser()) {
806            $this->error('user is not logged in (return)', [], true);
807            return $result;
808        }
809        if ($config = $this->getConfig()) {
810            if (!$config->usePatronAPI) {
811                $this->error('Return resource - OverDrive patron APIs are disabled.');
812                return $result;
813            }
814            $url = $config->circURL . "/v1/patrons/me/checkouts/$resourceID";
815            $action = 'DELETE';
816            $response = $this->callPatronUrl(
817                $user['cat_username'],
818                $user['cat_password'],
819                $url,
820                null,
821                $action
822            );
823
824            // Because this is a DELETE Call, we are just looking for a boolean
825            if ($response) {
826                $result->status = true;
827            } else {
828                $result->msg = $response->message;
829            }
830        }
831        return $result;
832    }
833
834    /**
835     * Get Download Redirect for an OverDrive Resource
836     *
837     * @param string $overDriveId OverDrive ID
838     *
839     * @return object Object with result. If successful, then data will
840     * have the download URI ($result->data->downloadRedirect)
841     */
842    public function getDownloadRedirect($overDriveId)
843    {
844        $result = $this->getResultObject();
845        $downloadLink = false;
846        if (!$user = $this->getUser()) {
847            $this->error('user is not logged in', false, true);
848            return $result;
849        }
850        if (($config = $this->getConfig()) && !$config->usePatronAPI) {
851            $this->error('Get download redirect - OverDrive patron APIs are disabled.');
852            return $result;
853        }
854        $checkout = $this->getCheckout($overDriveId, false);
855        if ($checkout) {
856            $dlRedirectUrl = $checkout->links->downloadRedirect->href;
857
858            $response = $this->callPatronUrl(
859                $user['cat_username'],
860                $user['cat_password'],
861                $dlRedirectUrl,
862                null,
863                'GET',
864                'redirect'
865            );
866            if (!empty($response)) {
867                $result->status = true;
868                $result->data = (object)[];
869                $result->data->downloadRedirect = $response;
870            } else {
871                $this->debug('OverDriveConnector: problem getting redirect for $overDriveId.');
872                $result->msg
873                    = 'Could not get redirect link for resourceID '
874                    . "[$overDriveId]";
875            }
876        } else {
877            $this->debug('OverDriveConnector: could not find checkout.');
878            $result->msg
879                = 'Could not get download redirect link for resourceID '
880                . "[$overDriveId]";
881        }
882        return $result;
883    }
884
885    /**
886     * Find the authentication header
887     *
888     * @return object
889     */
890    public function getAuthHeader()
891    {
892        $result = $this->getResultObject();
893        if (!$user = $this->getUser()) {
894            $this->error('user is not logged in (getauth header)', [], true);
895            return $result;
896        }
897        // todo: check result
898        $patronTokenData = $this->connectToPatronAPI(
899            $user['cat_username'],
900            $user['cat_password'],
901            $forceNewConnection = false
902        );
903        $authorizationData = $patronTokenData->token_type .
904            ' ' . $patronTokenData->access_token;
905        $header = "Authorization: $authorizationData";
906        $result->data->authheader = $header;
907        $result->status = true;
908        return $result;
909    }
910
911    /**
912     * Get Configuration
913     * Sets up a local copy of configurations for convenience
914     *
915     * @return bool|\stdClass
916     */
917    public function getConfig()
918    {
919        $conf = new \stdClass();
920        if (!$this->recordConfig) {
921            $this->error(
922                'Could not locate the OverDrive Record Driver '
923                . 'configuration.'
924            );
925            return false;
926        }
927        if ($this->recordConfig->API->productionMode == false) {
928            $conf->productionMode = false;
929            $conf->discURL = $this->recordConfig->API->integrationDiscoveryURL;
930            $conf->circURL = $this->recordConfig->API->integrationCircURL;
931            $conf->libraryID = $this->recordConfig->API->integrationLibraryID;
932            $conf->websiteID = $this->recordConfig->API->integrationWebsiteID;
933        } else {
934            $conf->productionMode = true;
935            $conf->discURL = $this->recordConfig->API->productionDiscoveryURL;
936            $conf->circURL = $this->recordConfig->API->productionCircURL;
937            $conf->libraryID = $this->recordConfig->API->productionLibraryID;
938            $conf->websiteID = $this->recordConfig->API->productionWebsiteID;
939        }
940
941        $conf->clientKey = $this->recordConfig->API->clientKey;
942        $conf->clientSecret = $this->recordConfig->API->clientSecret;
943        $conf->tokenURL = $this->recordConfig->API->tokenURL;
944        $conf->patronTokenURL = $this->recordConfig->API->patronTokenURL;
945        $conf->usePatronAPI = (bool)($this->recordConfig->API->usePatronAPI ?? true);
946        $conf->idField = $this->recordConfig->Overdrive->overdriveIdMarcField;
947        $conf->idSubfield
948            = $this->recordConfig->Overdrive->overdriveIdMarcSubfield;
949        $conf->ILSname = $this->recordConfig->API->ILSname;
950        $conf->isMarc = $this->recordConfig->Overdrive->isMarc;
951        $conf->displayDateFormat = $this->mainConfig->Site->displayDateFormat;
952        $conf->consortiumSupport
953            = $this->recordConfig->Overdrive->consortiumSupport;
954        $conf->showMyContent
955            = strtolower($this->recordConfig->Overdrive->showMyContent);
956        $conf->noAccessString = $this->recordConfig->Overdrive->noAccessString;
957        $conf->tokenCacheLifetime
958            = $this->recordConfig->API->tokenCacheLifetime;
959        $conf->libraryURL = $this->recordConfig->Overdrive->overdriveLibraryURL;
960        $conf->enableAjaxStatus = $this->recordConfig->Overdrive->enableAjaxStatus;
961        $conf->showOverdriveAdminMenu = $this->recordConfig->Overdrive->showOverdriveAdminMenu;
962        return $conf;
963    }
964
965    /**
966     * Returns an array of OverDrive Formats and translation tokens
967     *
968     * @return array
969     */
970    public function getFormatNames()
971    {
972        return [
973            'ebook-kindle' => 'od_ebook-kindle',
974            'ebook-overdrive' => 'od_ebook-overdrive',
975            'ebook-epub-adobe' => 'od_ebook-epub-adobe',
976            'ebook-epub-open' => 'od_ebook-epub-open',
977            'ebook-pdf-adobe' => 'od_ebook-pdf-adobe',
978            'ebook-pdf-open' => 'od_ebook-pdf-open',
979            'ebook-mediado' => 'od_ebook-mediado',
980            'audiobook-overdrive' => 'od_audiobook-overdrive',
981            'audiobook-mp3' => 'od_audiobook-mp3',
982            'video-streaming' => 'od_video-streaming',
983            'magazine-overdrive' => 'od_magazine-overdrive',
984        ];
985    }
986
987    /**
988     * Returns permanent links for OverDrive resources
989     *
990     * @param array $overDriveIds An array of OverDrive IDs we need links for
991     *
992     * @return array<string>
993     */
994    public function getPermanentLinks($overDriveIds = [])
995    {
996        $links = [];
997        if (!$overDriveIds || count($overDriveIds) < 1) {
998            $this->logWarning('OverDriveConnector: getPermanentLinks: no overdrive content IDs were passed in.');
999            return [];
1000        }
1001        if ($conf = $this->getConfig()) {
1002            $libraryURL = $conf->libraryURL;
1003            $md = $this->getMetadata($overDriveIds);
1004
1005            foreach ($md as $item) {
1006                $links[$item->id] = "$libraryURL/media/" . $item->crossRefId;
1007            }
1008        }
1009        return $links;
1010    }
1011
1012    /**
1013     * Returns all the issues for an overdrive magazine title
1014     *
1015     * @param string $overDriveId OverDrive Identifier for magazine title
1016     * @param bool   $checkouts   Whether to add checkout information to each issue
1017     * @param int    $limit       maximum number of issues to retrieve (default 100)
1018     * @param int    $offset      page of results (default 0)
1019     *
1020     * @return object results of metadata fetch
1021     */
1022    public function getMagazineIssues($overDriveId = false, $checkouts = false, $limit = 100, $offset = 0)
1023    {
1024        $result = $this->getResultObject();
1025        if (!$overDriveId) {
1026            $this->logWarning('OverDriveConnector - getMagazineIssues: no overdrive content ID was passed in.');
1027            $result->msg = 'no overdrive content ID was passed in.';
1028            return $result;
1029        }
1030        if ($conf = $this->getConfig()) {
1031            $libraryURL = $conf->libraryURL;
1032            $productsKey = $this->getCollectionToken();
1033            $baseUrl = $conf->discURL;
1034            $issuesURL = "$baseUrl/v1/collections/$productsKey/products/$overDriveId/issues";
1035            $issuesURL .= "?limit=$limit&offset=$offset";
1036            $response = $this->callUrl($issuesURL);
1037            if ($response) {
1038                $result->status = true;
1039                $result->data = (object)[];
1040                $result->data = $response;
1041            }
1042
1043            if ($checkouts) {
1044                $checkoutResult = $this->getCheckouts();
1045                $checkoutData = $checkoutResult->data;
1046                foreach ($result->data->products as $key => $issue) {
1047                    $issue->checkedout = isset($checkoutData[strtolower($issue->id)]);
1048                }
1049            }
1050        }
1051        return $result;
1052    }
1053
1054    /**
1055     * Returns a hash of metadata keyed on overdrive reserveID
1056     *
1057     * @param array $overDriveIds Set of OverDrive IDs
1058     *
1059     * @return array results of metadata fetch
1060     *
1061     * @todo if more than 25 passed in, make multiple calls
1062     */
1063    public function getMetadata($overDriveIds = [])
1064    {
1065        $metadata = [];
1066        if (!$overDriveIds || count($overDriveIds) < 1) {
1067            $this->logWarning('OverDriveConnector - getMetadata: no overdrive content IDs were passed in.');
1068            return [];
1069        }
1070        if ($conf = $this->getConfig()) {
1071            $libraryURL = $conf->libraryURL;
1072            $productsKey = $this->getCollectionToken();
1073            $baseUrl = $conf->discURL;
1074            $metadataUrl = "$baseUrl/v1/collections/$productsKey/";
1075            $metadataUrl .= 'bulkmetadata?reserveIds=' . implode(
1076                ',',
1077                $overDriveIds
1078            );
1079            $res = $this->callUrl($metadataUrl);
1080            $md = $res->metadata;
1081            foreach ($md as $item) {
1082                $item->external_url = "$libraryURL/media/" . $item->crossRefId;
1083                $metadata[$item->id] = $item;
1084            }
1085        }
1086        return $metadata;
1087    }
1088
1089    /**
1090     * For  array of titles passed in this will return the same array
1091     * with metadata attached to the records with the property name of 'metadata'
1092     *
1093     * @param array $overDriveTitles Assoc array of objects with OD IDs as keys (generally what
1094     *                               you get from getCheckouts and getHolds)
1095     *
1096     * @return array initial array with results of metadata attached as "metadata" property
1097     */
1098    public function getMetadataForTitles($overDriveTitles = [])
1099    {
1100        if (!$overDriveTitles || count($overDriveTitles) < 1) {
1101            $this->logWarning('OverDriveConnector - getMetadataForTitles: no overdrive content was passed in.');
1102            return [];
1103        }
1104        $metadata = $this->getMetadata(array_column($overDriveTitles, 'reserveId', 'reserveId'));
1105        foreach ($overDriveTitles as &$title) {
1106            $id = $title->reserveId;
1107            if (!isset($metadata[strtolower($id)])) {
1108                $this->logWarning('no metadata found for ' . strtolower($id));
1109            } else {
1110                $title->metadata = $metadata[strtolower($id)];
1111            }
1112        }
1113        return $overDriveTitles;
1114    }
1115
1116    /**
1117     * Get OverDrive Checkout
1118     *
1119     * Get the overdrive checkout object for an overdrive title
1120     * for the current user
1121     *
1122     * @param string $overDriveId OverDrive resource id
1123     * @param bool   $refresh     Whether or not to ignore cache and get latest
1124     *
1125     * @return object|false PHP object that represents the checkout or false
1126     * the checkout is not in the current list of checkouts for the current
1127     * user.
1128     */
1129    public function getCheckout($overDriveId, $refresh = true)
1130    {
1131        $result = $this->getCheckouts($refresh);
1132        if ($result->status) {
1133            $checkouts = $result->data;
1134            foreach ($checkouts as $checkout) {
1135                if (
1136                    strtolower($checkout->reserveId) == strtolower(
1137                        $overDriveId
1138                    )
1139                ) {
1140                    return $checkout;
1141                }
1142            }
1143            return false;
1144        } else {
1145            return false;
1146        }
1147    }
1148
1149    /**
1150     * Get OverDrive Hold
1151     *
1152     * Get the overdrive hold object for an overdrive title
1153     * for the current user
1154     *
1155     * @param string $overDriveId OverDrive resource id
1156     * @param bool   $refresh     Whether or not to ignore cache and get latest
1157     *
1158     * @return object|false PHP object that represents the checkout or false
1159     * the checkout is not in the current list of checkouts for the current
1160     * user.
1161     */
1162    public function getHold($overDriveId, $refresh = true)
1163    {
1164        $result = $this->getHolds($refresh);
1165        if ($result->status) {
1166            $holds = $result->data;
1167            foreach ($holds as $hold) {
1168                if (strtolower($hold->reserveId) == strtolower($overDriveId)) {
1169                    return $hold;
1170                }
1171            }
1172            return false;
1173        } else {
1174            return false;
1175        }
1176    }
1177
1178    /**
1179     * Get OverDrive Checkouts (for a user)
1180     *
1181     * @param bool $refresh Whether or not to ignore cache and get latest
1182     *
1183     * @return object Results of the call
1184     */
1185    public function getCheckouts($refresh = true)
1186    {
1187        // The checkouts are cached in the session, but we can force a refresh
1188        $result = $this->getResultObject();
1189
1190        if (!$user = $this->getUser()) {
1191            $this->error('OverDriveConnector - user is not logged in (getcheckouts)');
1192            return $result;
1193        }
1194
1195        $checkouts = $this->getSessionContainer()->checkouts;
1196        if (!$checkouts || $refresh) {
1197            if ($config = $this->getConfig()) {
1198                if (!$config->usePatronAPI) {
1199                    $this->error('Get checkouts - OverDrive patron APIs are disabled.');
1200                    return $result;
1201                }
1202                $url = $config->circURL . '/v1/patrons/me/checkouts';
1203
1204                $response = $this->callPatronUrl(
1205                    $user['cat_username'],
1206                    $user['cat_password'],
1207                    $url,
1208                    null
1209                );
1210                if (!empty($response)) {
1211                    $result->status = true;
1212                    $result->message = '';
1213                    if (isset($response->checkouts)) {
1214                        //reset checkouts array to be keyed by id
1215                        $mycheckouts = [];
1216                        foreach ($response->checkouts as $co) {
1217                            $mycheckouts[$co->reserveId] = $co;
1218                        }
1219
1220                        // get the metadata for these so we can check for magazines.
1221                        $result->data = (object)[];
1222                        $result->data = $this->getMetadataForTitles($mycheckouts);
1223
1224                        foreach ($mycheckouts as $key => $checkout) {
1225                            // Convert dates to desired format
1226                            $coExpires = new \DateTime($checkout->expires);
1227                            $result->data[$key]->expires = $coExpires->format(
1228                                $config->displayDateFormat
1229                            );
1230                            $result->data[$key]->isReturnable
1231                                = !$checkout->isFormatLockedIn;
1232                        }
1233                        $this->getSessionContainer()->checkouts
1234                            = $result->data;
1235                    } else {
1236                        $result->data = [];
1237                        $this->getSessionContainer()->checkouts = false;
1238                    }
1239                } else {
1240                    $accessResult = $this->getAccess();
1241                    return $accessResult;
1242                }
1243            } // no config...
1244        } else {
1245            $result->status = true;
1246            $result->msg = [];
1247            $result->data = (object)[];
1248            $result->data = $this->getSessionContainer()->checkouts;
1249        }
1250        return $result;
1251    }
1252
1253    /**
1254     * Get OverDrive Holds (for a user)
1255     *
1256     * @param bool $refresh Whether or not to ignore cache and get latest
1257     *
1258     * @return \stdClass Results of the call. the data property will be set
1259     *     to an empty array if there are no  holds.
1260     */
1261    public function getHolds($refresh = true)
1262    {
1263        $this->debug('OverDriveConnector - getHolds');
1264        $result = $this->getResultObject();
1265        if (!$user = $this->getUser()) {
1266            $this->error('OverDriveConnector - user is not logged in (getholds)');
1267            return $result;
1268        }
1269
1270        $holds = $this->getSessionContainer()->holds;
1271        if (!$holds || $refresh) {
1272            if ($config = $this->getConfig()) {
1273                if (!$config->usePatronAPI) {
1274                    $this->error('Get holds - OverDrive patron APIs are disabled.');
1275                    return $result;
1276                }
1277                $url = $config->circURL . '/v1/patrons/me/holds';
1278
1279                $response = $this->callPatronUrl(
1280                    $user['cat_username'],
1281                    $user['cat_password'],
1282                    $url
1283                );
1284
1285                $result->status = false;
1286                $result->message = '';
1287
1288                if (!empty($response)) {
1289                    $result->status = true;
1290                    $result->message = 'hold_place_success_html';
1291                    if (isset($response->holds)) {
1292                        $result->data = $response->holds;
1293                        // Check for holds ready for checkout
1294                        foreach ($response->holds as $key => $hold) {
1295                            // check for hold suspension
1296                            $result->data[$key]->holdSuspension = $hold->holdSuspension ?? false;
1297                            // check if ready for checkout
1298                            foreach ($hold->actions as $action => $value) {
1299                                if ($action == 'checkout') {
1300                                    $result->data[$key]->holdReadyForCheckout = true;
1301                                    // format the expires date.
1302                                    $holdExpires = new \DateTime($hold->holdExpires);
1303                                    $result->data[$key]->holdExpires
1304                                        = $holdExpires->format(
1305                                            (string)$config->displayDateFormat
1306                                        );
1307                                } else {
1308                                    $result->data[$key]->holdReadyForCheckout = false;
1309                                }
1310                            }
1311
1312                            $holdPlacedDate = new \DateTime($hold->holdPlacedDate);
1313                            $result->data[$key]->holdPlacedDate
1314                                = $holdPlacedDate->format(
1315                                    (string)$config->displayDateFormat
1316                                );
1317                        } // end foreach
1318                        $this->getSessionContainer()->holds = $response->holds;
1319                    } else {
1320                        // no holds found for this patron
1321                        $result->data = [];
1322                        $this->getSessionContainer()->holds;
1323                    }
1324                } else {
1325                    $result->code = 'od_code_connection_failed';
1326                }
1327            } // no config
1328        } else {
1329            $result->status = true;
1330            $result->message = [];
1331            $result->data = $this->getSessionContainer()->holds;
1332        }
1333        return $result;
1334    }
1335
1336    /**
1337     * Call a URL on the API
1338     *
1339     * @param string $url         The url to call
1340     * @param array  $headers     Headers to set for the request.
1341     *                            if null, then the auth headers are used.
1342     * @param bool   $checkToken  Whether to check and get a new token
1343     * @param string $requestType The request type (GET, POST etc)
1344     *
1345     * @return object|bool The json response from the API call
1346     *  converted to an object. If the call fails at the
1347     *  HTTP level then the error is logged and false is returned.
1348     */
1349    protected function callUrl(
1350        $url,
1351        $headers = null,
1352        $checkToken = true,
1353        $requestType = 'GET'
1354    ) {
1355        if (!$checkToken || $this->connectToAPI()) {
1356            $tokenData = $this->getSessionContainer()->tokenData;
1357            try {
1358                $client = $this->getHttpClient($url);
1359            } catch (Exception $e) {
1360                $this->error(
1361                    'OverDriveConnector - error while setting up the client: ' . $e->getMessage()
1362                );
1363                return false;
1364            }
1365            if ($headers === null) {
1366                $headers = [];
1367                if (
1368                    isset($tokenData->token_type)
1369                    && isset($tokenData->access_token)
1370                ) {
1371                    $headers[] = "Authorization: {$tokenData->token_type} "
1372                        . $tokenData->access_token;
1373                }
1374                $headers[] = 'User-Agent: VuFind';
1375            }
1376            $client->setHeaders($headers);
1377            $client->setMethod($requestType);
1378            $client->setUri($url);
1379            try {
1380                // throw new Exception('testException');
1381                $response = $client->send();
1382            } catch (Exception $ex) {
1383                $this->error(
1384                    'Exception during request: ' .
1385                    $ex->getMessage()
1386                );
1387                return false;
1388            }
1389
1390            if ($response->isServerError()) {
1391                $this->error(
1392                    'OverDrive HTTP Error: ' .
1393                    $response->getStatusCode()
1394                );
1395                $this->debug('Request: ' . $client->getRequest());
1396                $this->debug('Response: ' . $client->getResponse());
1397                return false;
1398            }
1399
1400            $body = $response->getBody();
1401            $returnVal = json_decode($body);
1402            if ($returnVal != null) {
1403                if (isset($returnVal->errorCode)) {
1404                    // In some cases, this should be returned perhaps...
1405                    $this->error('OverDrive Error: ' . $returnVal->errorCode);
1406                    return $returnVal;
1407                } else {
1408                    return $returnVal;
1409                }
1410            } else {
1411                $this->error(
1412                    'OverDrive Error: Nothing returned from API call.'
1413                );
1414                $this->debug(
1415                    'Body return from OD API Call: ' . print_r($body, true)
1416                );
1417            }
1418        }
1419        return false;
1420    }
1421
1422    /**
1423     * Connect to API
1424     *
1425     * @param bool $forceNewConnection Force a new connection (get a new token)
1426     *
1427     * @return string token for the session or false
1428     *     if the token request failed
1429     */
1430    protected function connectToAPI($forceNewConnection = false)
1431    {
1432        $conf = $this->getConfig();
1433        $tokenData = $this->getSessionContainer()->tokenData;
1434        if (
1435            $forceNewConnection || $tokenData == null
1436            || !isset($tokenData->access_token)
1437            || time() >= $tokenData->expirationTime
1438        ) {
1439            $authHeader = base64_encode(
1440                $conf->clientKey . ':' . $conf->clientSecret
1441            );
1442            $headers = [
1443                'Content-Type: application/x-www-form-urlencoded;charset=UTF-8',
1444                "Authorization: Basic $authHeader",
1445            ];
1446
1447            try {
1448                $client = $this->getHttpClient();
1449            } catch (Exception $e) {
1450                $this->error(
1451                    'error while setting up the client: ' . $e->getMessage()
1452                );
1453                return false;
1454            }
1455            $client->setHeaders($headers);
1456            $client->setMethod('POST');
1457            $client->setRawBody('grant_type=client_credentials');
1458            $response = $client->setUri($conf->tokenURL)->send();
1459
1460            if ($response->isServerError()) {
1461                $this->error(
1462                    'OverDrive HTTP Error: ' .
1463                    $response->getStatusCode()
1464                );
1465                $this->debug('Request: ' . $client->getRequest());
1466                return false;
1467            }
1468
1469            $body = $response->getBody();
1470            $tokenData = json_decode($body);
1471            if ($tokenData != null) {
1472                if (isset($tokenData->errorCode)) {
1473                    // In some cases, this should be returned perhaps...
1474                    $this->error('OverDrive Error: ' . $tokenData->errorCode);
1475                    return false;
1476                } else {
1477                    $tokenData->expirationTime = time()
1478                        + ($tokenData->expires_in ?? 0);
1479                    $this->getSessionContainer()->tokenData = $tokenData;
1480                    return $tokenData;
1481                }
1482            } else {
1483                $this->error(
1484                    'OverDrive Error: Nothing returned from API call.'
1485                );
1486                $this->debug(
1487                    'Body return from OD API Call: ' . print_r($body, true)
1488                );
1489            }
1490        }
1491        return $tokenData;
1492    }
1493
1494    /**
1495     * Call a Patron URL on the API
1496     *
1497     * The patron URL is used for the circulation API's and requires a patron
1498     * specific token.
1499     *
1500     * @param string $patronBarcode Patrons barcode
1501     * @param string $patronPin     Patrons password
1502     * @param string $url           The url to call
1503     * @param array  $params        parameters to call
1504     * @param string $requestType   HTTP request type (default=GET)
1505     * @param string $returnType    options are json(def),body,redirect
1506     *
1507     * @return object|bool The json response from the API call
1508     *  converted to an object. If body is specified, the raw body is returned.
1509     *  If redirect, then it returns the URL specified in the redirect header.
1510     *  If the call fails at the HTTP level then the error is logged and false is returned.
1511     */
1512    protected function callPatronUrl(
1513        $patronBarcode,
1514        $patronPin,
1515        $url,
1516        $params = null,
1517        $requestType = 'GET',
1518        $returnType = 'json'
1519    ) {
1520        if ($this->connectToPatronAPI($patronBarcode, $patronPin, false)) {
1521            $patronTokenData = $this->getSessionContainer()->patronTokenData;
1522            $authorizationData = $patronTokenData->token_type .
1523                ' ' . $patronTokenData->access_token;
1524            $headers = [
1525                "Authorization: $authorizationData",
1526                'User-Agent: VuFind',
1527                'Content-Type: application/json',
1528            ];
1529            try {
1530                $client = $this->getHttpClient(null, $returnType != 'redirect');
1531            } catch (Exception $e) {
1532                $this->error(
1533                    'error while setting up the client: ' . $e->getMessage()
1534                );
1535                return false;
1536            }
1537            $client->setHeaders($headers);
1538            $client->setMethod($requestType);
1539            $this->debug('callPatronURL method: ' . $client->getMethod() . " url: $url");
1540            $client->setUri($url);
1541            if ($params != null) {
1542                $jsonData = ['fields' => []];
1543                foreach ($params as $key => $value) {
1544                    $jsonData['fields'][] = [
1545                        'name' => $key,
1546                        'value' => $value,
1547                    ];
1548                }
1549                $postData = json_encode($jsonData);
1550                $client->setRawBody($postData);
1551                $this->debug("patron data sent: $postData");
1552            }
1553
1554            try {
1555                $response = $client->send();
1556            } catch (Exception $ex) {
1557                $this->error(
1558                    'Exception during request: ' .
1559                    $ex->getMessage()
1560                );
1561                return false;
1562            }
1563            $body = $response->getBody();
1564
1565            // if all goes well for DELETE, the code will be 204
1566            // and response is empty.
1567            if ($requestType == 'DELETE' || $requestType == 'PUT') {
1568                if ($response->getStatusCode() == 204) {
1569                    return true;
1570                } else {
1571                    $this->error(
1572                        $requestType . ' Patron call failed. HTTP return code: ' .
1573                        $response->getStatusCode() . " body: $body"
1574                    );
1575                    return false;
1576                }
1577            }
1578
1579            if ($returnType == 'body') {
1580                // probably need to check for return status
1581                return $body;
1582            } elseif ($returnType == 'redirect') {
1583                $headers = $response->getHeaders();
1584                if ($headers->has('location')) {
1585                    $loc = $headers->get('location');
1586                    $uri = $loc->getUri();
1587                    return $uri;
1588                } else {
1589                    $this->error(
1590                        'OverDrive Error: returnType is redirect but no redirect found.'
1591                    );
1592                    return false;
1593                }
1594            } else {
1595                $returnVal = json_decode($body);
1596                if ($returnVal != null) {
1597                    if (
1598                        !isset($returnVal->message)
1599                        || $returnVal->message != 'An unexpected error has occurred.'
1600                    ) {
1601                        return $returnVal;
1602                    } else {
1603                        $this->debug(
1604                            'OverDrive API problem: ' . $returnVal->message
1605                        );
1606                    }
1607                } else {
1608                    $this->error(
1609                        'OverDrive Error: Nothing returned from API call.'
1610                    );
1611                    return false;
1612                }
1613            }
1614        } else {
1615            $this->error('OverDrive Error: Not connected to the Patron API.');
1616        }
1617        return false;
1618    }
1619
1620    /**
1621     * Connect to Patron API
1622     *
1623     * @param string $patronBarcode      Patrons barcode
1624     * @param string $patronPin          Patrons password
1625     * @param bool   $forceNewConnection force a new connection (get a new
1626     *                                   token)
1627     *
1628     * @return object|bool token for the session
1629     */
1630    protected function connectToPatronAPI(
1631        $patronBarcode,
1632        $patronPin = '1234',
1633        $forceNewConnection = false
1634    ) {
1635        $patronTokenData = $this->getSessionContainer()->patronTokenData;
1636        $config = $this->getConfig();
1637        if (!$config->usePatronAPI) {
1638            return false;
1639        }
1640        if (
1641            $forceNewConnection
1642            || $patronTokenData == null
1643            || (isset($patronTokenData->expirationTime)
1644            and time() >= $patronTokenData->expirationTime)
1645        ) {
1646            $url = $config->patronTokenURL;
1647            $websiteId = $config->websiteID;
1648            $ilsname = $config->ILSname;
1649            $authHeader = base64_encode(
1650                $config->clientKey . ':' . $config->clientSecret
1651            );
1652            $headers = [
1653                'Content-Type: application/x-www-form-urlencoded;charset=UTF-8',
1654                "Authorization: Basic $authHeader",
1655                'User-Agent: VuFind',
1656            ];
1657            try {
1658                $client = $this->getHttpClient($url);
1659            } catch (Exception $e) {
1660                $this->error(
1661                    'error while setting up the client: ' . $e->getMessage()
1662                );
1663                return false;
1664            }
1665            $client->setHeaders($headers);
1666            $client->setMethod('POST');
1667            if ($patronPin == null) {
1668                $postFields = "grant_type=password&username={$patronBarcode}";
1669                $postFields .= '&password=ignore&password_required=false';
1670                $postFields .= "&scope=websiteId:{$websiteId}%20";
1671                $postFields .= "authorizationname:{$ilsname}";
1672            } else {
1673                $postFields = "grant_type=password&username={$patronBarcode}";
1674                $postFields .= "&password={$patronPin}&scope=websiteId";
1675                $postFields .= ":{$websiteId}%20authorizationname:{$ilsname}";
1676            }
1677            $client->setRawBody($postFields);
1678            $response = $client->setUri($url)->send();
1679            $body = $response->getBody();
1680            $patronTokenData = json_decode($body);
1681
1682            if (isset($patronTokenData->expires_in)) {
1683                $patronTokenData->expirationTime = time()
1684                    + $patronTokenData->expires_in;
1685            } else {
1686                $this->debug(
1687                    'problem with OD patron API token Call: ' .
1688                    print_r(
1689                        $patronTokenData,
1690                        true
1691                    )
1692                );
1693                // If we have an unauthorized error, then we are going
1694                // to cache that in the session so we don't keep making
1695                // unnecessary calls; otherwise, just don't store the tokenData
1696                // object so that it gets checked again next time
1697                if ($patronTokenData->error == 'unauthorized_client') {
1698                    $this->getSessionContainer()->odAccessMessage
1699                        = $patronTokenData->error_description;
1700                    $this->getSessionContainer()->patronTokenData
1701                        = $patronTokenData;
1702                } else {
1703                    $patronTokenData = null;
1704                }
1705                return false;
1706            }
1707            $this->getSessionContainer()->patronTokenData = $patronTokenData;
1708        }
1709        if (isset($patronTokenData->error)) {
1710            return false;
1711        }
1712        return $patronTokenData;
1713    }
1714
1715    /**
1716     * Get an HTTP client
1717     *
1718     * @param string $url            URL for client to use
1719     * @param bool   $allowRedirects Whether to allow the client to follow redirects
1720     *
1721     * @return \Laminas\Http\Client
1722     * @throws \Exception
1723     */
1724    protected function getHttpClient($url = null, $allowRedirects = true)
1725    {
1726        if (null === $this->httpService) {
1727            throw new Exception('HTTP service missing.');
1728        }
1729        if (!$this->client) {
1730            $this->client = $this->httpService->createClient($url);
1731            // Set keep alive to true since we are sending to the same server
1732            $this->client->setOptions(['keepalive', true]);
1733        }
1734        $this->client->resetParameters();
1735        // set keep alive to true since we are sending to the same server
1736        $options = ['keepalive' => true];
1737        if (!$allowRedirects) {
1738            $options['maxredirects'] = 0;
1739        }
1740        $this->client->setOptions($options);
1741        return $this->client;
1742    }
1743
1744    /**
1745     * Set a cache storage object.
1746     *
1747     * @param StorageInterface $cache Cache storage interface
1748     *
1749     * @return void
1750     */
1751    public function setCacheStorage(StorageInterface $cache = null)
1752    {
1753        $this->cache = $cache;
1754    }
1755
1756    /**
1757     * Helper function for fetching cached data.
1758     * Data is cached for up to $this->cacheLifetime seconds so that it would
1759     * be
1760     * faster to process e.g. requests where multiple calls to the backend are
1761     * made.
1762     *
1763     * @param string $key Cache entry key
1764     *
1765     * @return mixed|null Cached entry or null if not cached or expired
1766     */
1767    protected function getCachedData($key)
1768    {
1769        // No cache object, no cached results!
1770        if (null === $this->cache) {
1771            return null;
1772        }
1773        $conf = $this->getConfig();
1774        $fullKey = $this->getCacheKey($key);
1775        $item = $this->cache->getItem($fullKey);
1776        if (null !== $item) {
1777            // Return value if still valid:
1778            if (time() - $item['time'] < $conf->tokenCacheLifetime) {
1779                return $item['entry'];
1780            }
1781
1782            // Clear expired item from cache:
1783            $this->cache->removeItem($fullKey);
1784        }
1785        return null;
1786    }
1787
1788    /**
1789     * Helper function for storing cached data.
1790     * Data is cached for up to $this->cacheLifetime seconds so that it would
1791     * be
1792     * faster to process e.g. requests where multiple calls to the backend are
1793     * made.
1794     *
1795     * @param string $key   Cache entry key
1796     * @param mixed  $entry Entry to be cached
1797     *
1798     * @return void
1799     */
1800    protected function putCachedData($key, $entry)
1801    {
1802        // Don't write to cache if we don't have a cache!
1803        if (null === $this->cache) {
1804            return;
1805        }
1806        $item = [
1807            'time' => time(),
1808            'entry' => $entry,
1809        ];
1810        $this->cache->setItem($this->getCacheKey($key), $item);
1811    }
1812
1813    /**
1814     * Helper function for removing cached data.
1815     *
1816     * @param string $key Cache entry key
1817     *
1818     * @return void
1819     */
1820    protected function removeCachedData($key)
1821    {
1822        // Don't write to cache if we don't have a cache!
1823        if (null === $this->cache) {
1824            return;
1825        }
1826        $this->cache->removeItem($this->getCacheKey($key));
1827    }
1828
1829    /**
1830     * Get Result Object
1831     *
1832     * @param bool   $status Whether it succeeded
1833     * @param string $msg    More information
1834     * @param string $code   code used for end user display/translation
1835     *
1836     * @return object
1837     */
1838    public function getResultObject($status = false, $msg = '', $code = '')
1839    {
1840        return (object)[
1841            'status' => $status,
1842            'msg' => $msg,
1843            'data' => false,
1844            'code' => $code,
1845        ];
1846    }
1847}