Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 1085
0.00% covered (danger)
0.00%
0 / 57
CRAP
0.00% covered (danger)
0.00%
0 / 1
MyResearchController
0.00% covered (danger)
0.00%
0 / 1085
0.00% covered (danger)
0.00%
0 / 57
90902
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
 processAuthenticationException
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
56
 storeRefererForPostLoginRedirect
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 homeAction
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
342
 accountAction
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
56
 loginAction
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 userloginAction
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 completeLoginAction
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 logoutAction
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 getSearchRowSecurely
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 setSavedFlagSecurely
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getUserVerificationContainer
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 scheduleSearch
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
30
 schedulesearchAction
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
56
 isDuplicateOfSavedSearch
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 savesearchAction
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
90
 profileAction
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
210
 addAccountBlocksToFlashMessenger
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 catalogloginAction
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 favoritesAction
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 deleteAction
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
132
 performDeleteFavorite
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 processEditSubmit
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
30
 editAction
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
110
 confirmDeleteFavorite
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 mylistAction
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
110
 processEditList
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
56
 editlistAction
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
110
 emailNotVerifiedAction
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 deletelistAction
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
30
 holdsAction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 storageRetrievalRequestsAction
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
90
 illRequestsAction
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
72
 checkedoutAction
0.00% covered (danger)
0.00%
0 / 71
0.00% covered (danger)
0.00%
0 / 1
240
 historicloansAction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 finesAction
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
72
 getSessionInitiator
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 recoverAction
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
56
 sendRecoveryEmail
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
20
 sendFirstVerificationEmail
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 sendChangeNotificationEmail
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 sendVerificationEmail
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
72
 verifyAction
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 verifyEmailAction
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 resetNewPasswordForm
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 newPasswordAction
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
110
 changeEmailAction
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
90
 changePasswordAction
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 deleteLoginTokenAction
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 deleteUserLoginTokensAction
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getHashAge
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setUpAuthenticationFromRequest
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 deleteAccountAction
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
42
 unsubscribeAction
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
56
 getPaginationHelper
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 listTagsEnabled
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 addPendingEmailChangeMessage
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3/**
4 * MyResearch Controller
5 *
6 * PHP version 8
7 *
8 * Copyright (C) Villanova University 2010.
9 * Copyright (C) The National Library of Finland 2023.
10 *
11 * This program is free software; you can redistribute it and/or modify
12 * it under the terms of the GNU General Public License version 2,
13 * as published by the Free Software Foundation.
14 *
15 * This program is distributed in the hope that it will be useful,
16 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18 * GNU General Public License for more details.
19 *
20 * You should have received a copy of the GNU General Public License
21 * along with this program; if not, write to the Free Software
22 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
23 *
24 * @category VuFind
25 * @package  Controller
26 * @author   Demian Katz <demian.katz@villanova.edu>
27 * @author   Ere Maijala <ere.maijala@helsinki.fi>
28 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
29 * @link     https://vufind.org Main Site
30 */
31
32namespace VuFind\Controller;
33
34use DateTime;
35use Exception;
36use Laminas\ServiceManager\ServiceLocatorInterface;
37use Laminas\Session\Container;
38use Laminas\View\Model\ViewModel;
39use VuFind\Account\UserAccountService;
40use VuFind\Auth\ILSAuthenticator;
41use VuFind\Controller\Feature\ListItemSelectionTrait;
42use VuFind\Crypt\SecretCalculator;
43use VuFind\Db\Entity\SearchEntityInterface;
44use VuFind\Db\Entity\UserEntityInterface;
45use VuFind\Db\Service\SearchServiceInterface;
46use VuFind\Db\Service\UserListServiceInterface;
47use VuFind\Db\Service\UserResourceServiceInterface;
48use VuFind\Db\Service\UserServiceInterface;
49use VuFind\Exception\Auth as AuthException;
50use VuFind\Exception\AuthEmailNotVerified as AuthEmailNotVerifiedException;
51use VuFind\Exception\AuthInProgress as AuthInProgressException;
52use VuFind\Exception\BadRequest as BadRequestException;
53use VuFind\Exception\Forbidden as ForbiddenException;
54use VuFind\Exception\ListPermission as ListPermissionException;
55use VuFind\Exception\LoginRequired as LoginRequiredException;
56use VuFind\Exception\Mail as MailException;
57use VuFind\Exception\MissingField as MissingFieldException;
58use VuFind\Favorites\FavoritesService;
59use VuFind\ILS\PaginationHelper;
60use VuFind\Mailer\Mailer;
61use VuFind\Search\RecommendListener;
62use VuFind\Tags\TagsService;
63use VuFind\Validator\CsrfInterface;
64
65use function count;
66use function in_array;
67use function intval;
68use function is_array;
69use function is_object;
70
71/**
72 * Controller for the user account area.
73 *
74 * @category VuFind
75 * @package  Controller
76 * @author   Demian Katz <demian.katz@villanova.edu>
77 * @author   Ere Maijala <ere.maijala@helsinki.fi>
78 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
79 * @link     https://vufind.org Main Site
80 */
81class MyResearchController extends AbstractBase
82{
83    use Feature\BulkActionControllerTrait;
84    use Feature\CatchIlsExceptionsTrait;
85    use \VuFind\ILS\Logic\SummaryTrait;
86    use ListItemSelectionTrait;
87
88    /**
89     * Configuration loader
90     *
91     * @var \VuFind\Config\PluginManager
92     */
93    protected $configLoader;
94
95    /**
96     * Permission that must be granted to access this module (false for no
97     * restriction, null to use configured default (which is usually the same
98     * as false)).
99     *
100     * For this controller, we default to false rather than null because
101     * we don't want a default setting to override the controller's accessibility
102     * and break the login process!
103     *
104     * @var string|bool
105     */
106    protected $accessPermission = false;
107
108    /**
109     * Export support class
110     *
111     * @var \VuFind\Export
112     */
113    protected $export;
114
115    /**
116     * ILS Pagination Helper
117     *
118     * @var PaginationHelper
119     */
120    protected $paginationHelper = null;
121
122    /**
123     * Session container
124     *
125     * @var Container
126     */
127    protected $session;
128
129    /**
130     * Constructor
131     *
132     * @param ServiceLocatorInterface      $sm           Service locator
133     * @param Container                    $container    Session container
134     * @param \VuFind\Config\PluginManager $configLoader Configuration loader
135     * @param \VuFind\Export               $export       Export support class
136     */
137    public function __construct(
138        ServiceLocatorInterface $sm,
139        Container $container,
140        \VuFind\Config\PluginManager $configLoader,
141        \VuFind\Export $export
142    ) {
143        parent::__construct($sm);
144        $this->session = $container;
145        $this->configLoader = $configLoader;
146        $this->export = $export;
147    }
148
149    /**
150     * Process an authentication error.
151     *
152     * @param AuthException $e Exception to process.
153     *
154     * @return void
155     */
156    protected function processAuthenticationException(AuthException $e)
157    {
158        $msg = $e->getMessage();
159        if ($e instanceof AuthInProgressException) {
160            $this->flashMessenger()->addSuccessMessage($msg);
161            return;
162        }
163        if ($e instanceof AuthEmailNotVerifiedException) {
164            $this->sendFirstVerificationEmail($e->getUser());
165            if ($msg == 'authentication_error_email_not_verified_html') {
166                $this->getUserVerificationContainer()->user = $e->getUser()->getUsername();
167                $url = $this->url()->fromRoute('myresearch-emailnotverified')
168                    . '?reverify=true';
169                $msg = [
170                    'html' => true,
171                    'msg' => $msg,
172                    'tokens' => ['%%url%%' => $url],
173                ];
174            }
175        }
176        // If a Shibboleth-style login has failed and the user just logged
177        // out, we need to override the error message with a more relevant
178        // one:
179        if (
180            $msg == 'authentication_error_admin'
181            && $this->getAuthManager()->userHasLoggedOut()
182            && $this->getSessionInitiator()
183        ) {
184            $msg = 'authentication_error_loggedout';
185        }
186        $this->flashMessenger()->addMessage($msg, 'error');
187    }
188
189    /**
190     * Maintaining this method for backwards compatibility;
191     * logic moved to parent and method re-named
192     *
193     * @return void
194     */
195    protected function storeRefererForPostLoginRedirect()
196    {
197        $this->setFollowupUrlToReferer();
198    }
199
200    /**
201     * Prepare and direct the home page where it needs to go
202     *
203     * @return mixed
204     */
205    public function homeAction()
206    {
207        // Process login request, if necessary (either because a form has been
208        // submitted or because we're using an external login provider):
209        if (
210            $this->params()->fromPost('processLogin')
211            || $this->getSessionInitiator()
212            || $this->params()->fromPost('auth_method')
213            || $this->params()->fromQuery('auth_method')
214        ) {
215            try {
216                if (!$this->getAuthManager()->getIdentity()) {
217                    $this->getAuthManager()->login($this->getRequest());
218                    // Return early to avoid unnecessary processing if we are being
219                    // called from login lightbox and don't have a followup action or
220                    // followup is set to referrer.
221                    if (
222                        $this->params()->fromPost('processLogin')
223                        && $this->inLightbox()
224                        && (!$this->hasFollowupUrl()
225                        || $this->followup()->retrieve('isReferrer') === true)
226                    ) {
227                        $this->clearFollowupUrl();
228                        return $this->getRefreshResponse();
229                    }
230                }
231            } catch (AuthException $e) {
232                $this->processAuthenticationException($e);
233            }
234        }
235
236        // Not logged in?  Force user to log in:
237        if (!$this->getAuthManager()->getIdentity()) {
238            if (
239                $this->followup()->retrieve('lightboxParent')
240                && $url = $this->getAndClearFollowupUrl(true)
241            ) {
242                return $this->redirect()->toUrl($url);
243            }
244
245            // Allow bypassing of post-login redirect
246            if ($this->params()->fromQuery('redirect', true)) {
247                $this->setFollowupUrlToReferer();
248            }
249            return $this->forwardTo('MyResearch', 'Login');
250        }
251        // Logged in?  Forward user to followup action
252        // or default action (if no followup provided):
253        if ($url = $this->getAndClearFollowupUrl(true)) {
254            return $this->redirect()->toUrl($url);
255        }
256
257        $config = $this->getConfig();
258        $page = $config->Site->defaultAccountPage ?? 'Favorites';
259
260        // Default to search history if favorites are disabled:
261        if ($page == 'Favorites' && !$this->listsEnabled()) {
262            return $this->forwardTo('Search', 'History');
263        }
264        return $this->forwardTo('MyResearch', $page);
265    }
266
267    /**
268     * "Create account" action
269     *
270     * @return mixed
271     */
272    public function accountAction()
273    {
274        // If the user is already logged in, don't let them create an account:
275        if ($this->getAuthManager()->getIdentity()) {
276            return $this->redirect()->toRoute('myresearch-home');
277        }
278        // If authentication mechanism does not support account creation, send
279        // the user away!
280        $method = trim($this->params()->fromQuery('auth_method'));
281        if (!$this->getAuthManager()->supportsCreation($method)) {
282            return $this->forwardTo('MyResearch', 'Home');
283        }
284
285        // If there's already a followup url, keep it; otherwise set one.
286        if (!$this->hasFollowupUrl()) {
287            $this->setFollowupUrlToReferer();
288        }
289
290        // Make view
291        $view = $this->createViewModel();
292        // Username policy
293        $view->usernamePolicy = $this->getAuthManager()->getUsernamePolicy($method);
294        // Password policy
295        $view->passwordPolicy = $this->getAuthManager()->getPasswordPolicy($method);
296        // Set up Captcha
297        $view->useCaptcha = $this->captcha()->active('newAccount');
298        // Pass request to view so we can repopulate user parameters in form:
299        $view->request = $this->getRequest()->getPost();
300        // Process request, if necessary:
301        if ($this->formWasSubmitted(useCaptcha: $view->useCaptcha)) {
302            try {
303                $this->getAuthManager()->create($this->getRequest());
304                return $this->forwardTo('MyResearch', 'Home');
305            } catch (AuthEmailNotVerifiedException $e) {
306                $this->sendFirstVerificationEmail($e->getUser());
307                return $this->redirect()->toRoute('myresearch-emailnotverified');
308            } catch (AuthException $e) {
309                $this->flashMessenger()->addMessage($e->getMessage(), 'error');
310            }
311        } else {
312            // If we are not processing a submission, we need to simply display
313            // an empty form. In case ChoiceAuth is being used, we may need to
314            // override the active authentication method based on request
315            // parameters to ensure display of the appropriate template.
316            $this->setUpAuthenticationFromRequest();
317        }
318        return $view;
319    }
320
321    /**
322     * Login Action
323     *
324     * @return mixed
325     */
326    public function loginAction()
327    {
328        // If this authentication method doesn't use a VuFind-generated login
329        // form, force it through:
330        if ($this->getSessionInitiator()) {
331            // Don't get stuck in an infinite loop -- if processLogin is already
332            // set, it probably means Home action is forwarding back here to
333            // report an error!
334            //
335            // Also don't attempt to process a login that hasn't happened yet;
336            // if we've just been forced here from another page, we need the user
337            // to click the session initiator link before anything can happen.
338            if (
339                !$this->params()->fromPost('processLogin', false)
340                && !$this->params()->fromPost('forcingLogin', false)
341            ) {
342                $this->getRequest()->getPost()->set('processLogin', true);
343                return $this->forwardTo('MyResearch', 'Home');
344            }
345        }
346
347        // Make request available to view for form updating:
348        $view = $this->createViewModel();
349        $view->request = $this->getRequest()->getPost();
350        return $view;
351    }
352
353    /**
354     * User login action -- clear any previous follow-up information prior to
355     * triggering a login process. This is used for explicit login links within
356     * the UI to differentiate them from contextual login links that are triggered
357     * by attempting to access protected actions.
358     *
359     * @return mixed
360     */
361    public function userloginAction()
362    {
363        // Don't log in if already logged in!
364        if ($this->getAuthManager()->getIdentity()) {
365            return $this->inLightbox()  // different behavior for lightbox context
366                ? $this->getRefreshResponse()
367                : $this->redirect()->toRoute('home');
368        }
369        $this->clearFollowupUrl();
370        // Set followup with the isReferrer flag so that the post-login process
371        // can decide whether to use it:
372        $this->setFollowupUrlToReferer(true, ['isReferrer' => true]);
373
374        if ($si = $this->getSessionInitiator()) {
375            return $this->redirect()->toUrl($si);
376        }
377        return $this->forwardTo('MyResearch', 'Login');
378    }
379
380    /**
381     * Complete login - perform a user login followed by a catalog login.
382     *
383     * @return mixed
384     */
385    public function completeLoginAction()
386    {
387        if (!$this->getAuthManager()->getIdentity()) {
388            return $this->forceLogin('');
389        }
390        if (!is_array($patron = $this->catalogLogin())) {
391            return $patron;
392        }
393        return $this->inLightbox()
394            ? $this->getRefreshResponse()
395            : $this->redirect()->toRoute('home');
396    }
397
398    /**
399     * Logout Action
400     *
401     * @return mixed
402     */
403    public function logoutAction()
404    {
405        $config = $this->getConfig();
406        if (!empty($config->Site->logOutRoute)) {
407            $logoutTarget = $this->getServerUrl($config->Site->logOutRoute);
408        } else {
409            $logoutTarget = $this->getRequest()->getServer()->get('HTTP_REFERER');
410            if (empty($logoutTarget) || !$this->isLocalUrl($logoutTarget)) {
411                $logoutTarget = $this->getServerUrl('home');
412            }
413
414            // If there is an auth_method parameter in the query, we should strip
415            // it out. Otherwise, the user may get stuck in an infinite loop of
416            // logging out and getting logged back in when using environment-based
417            // authentication methods like Shibboleth.
418            $logoutTarget = preg_replace(
419                '/([?&])auth_method=[^&]*&?/',
420                '$1',
421                $logoutTarget
422            );
423            $logoutTarget = rtrim($logoutTarget, '?');
424
425            // Another special case: if logging out will send the user back to
426            // the MyResearch home action, instead send them all the way to
427            // VuFind home. Otherwise, they might get logged back in again,
428            // which is confusing. Even in the best scenario, they'll just end
429            // up on a login screen, which is not helpful.
430            if ($logoutTarget == $this->getServerUrl('myresearch-home')) {
431                $logoutTarget = $this->getServerUrl('home');
432            }
433        }
434
435        return $this->redirect()
436            ->toUrl($this->getAuthManager()->logout($logoutTarget));
437    }
438
439    /**
440     * Get a search row, but throw an exception if it is not owned by the specified
441     * user or current active session.
442     *
443     * @param int $searchId ID of search row
444     * @param int $userId   ID of active user
445     *
446     * @throws ForbiddenException
447     * @return SearchEntityInterface
448     */
449    protected function getSearchRowSecurely($searchId, $userId)
450    {
451        $sessId = $this->serviceLocator
452            ->get(\Laminas\Session\SessionManager::class)->getId();
453        $search = $this->getDbService(SearchServiceInterface::class)
454            ->getSearchByIdAndOwner($searchId, $sessId, $userId);
455        if (empty($search)) {
456            throw new ForbiddenException('Access denied.');
457        }
458        return $search;
459    }
460
461    /**
462     * Support method for savesearchAction(): set the saved flag in a secure
463     * fashion, throwing an exception if somebody attempts something invalid.
464     *
465     * @param int  $searchId The search ID to save/unsave
466     * @param bool $saved    The new desired state of the saved flag
467     * @param int  $userId   The user ID requesting the change
468     *
469     * @throws \Exception
470     * @return void
471     */
472    protected function setSavedFlagSecurely($searchId, $saved, $userId)
473    {
474        $row = $this->getSearchRowSecurely($searchId, $userId);
475        $row->saved = $saved ? 1 : 0;
476        if (!$saved) {
477            $row->notification_frequency = 0;
478        }
479        $row->user_id = $userId;
480        $row->save();
481    }
482
483    /**
484     * Return a session container for use in user email verification.
485     *
486     * @return \Laminas\Session\Container
487     */
488    protected function getUserVerificationContainer()
489    {
490        return new \Laminas\Session\Container(
491            'user_verification',
492            $this->serviceLocator->get(\Laminas\Session\SessionManager::class)
493        );
494    }
495
496    /**
497     * Support method for savesearchAction() -- schedule a search.
498     *
499     * @param UserEntityInterface $user     Logged-in user object
500     * @param int                 $schedule Requested schedule setting
501     * @param int                 $sid      Search ID to schedule
502     *
503     * @return mixed
504     */
505    protected function scheduleSearch(UserEntityInterface $user, $schedule, $sid)
506    {
507        // Fail if scheduled searches are disabled.
508        $scheduleOptions = $this->serviceLocator
509            ->get(\VuFind\Search\History::class)
510            ->getScheduleOptions();
511        if (!isset($scheduleOptions[$schedule])) {
512            throw new ForbiddenException('Illegal schedule option: ' . $schedule);
513        }
514        $baseurl = rtrim($this->getServerUrl('home'), '/');
515        $userId = $user->getId();
516        $savedRow = $this->getSearchRowSecurely($sid, $userId);
517
518        // In case the user has just logged in, let's deduplicate...
519        $sessId = $this->serviceLocator
520            ->get(\Laminas\Session\SessionManager::class)->getId();
521        $duplicateId = $this->isDuplicateOfSavedSearch(
522            $savedRow,
523            $sessId,
524            $userId
525        );
526        if ($duplicateId) {
527            $savedRow->delete();
528            $sid = $duplicateId;
529            $savedRow = $this->getSearchRowSecurely($sid, $userId);
530        }
531
532        // If we didn't find an already-saved row, let's save and retry:
533        if (!($savedRow->saved ?? false)) {
534            $this->setSavedFlagSecurely($sid, true, $userId);
535            $savedRow = $this->getSearchRowSecurely($sid, $userId);
536        }
537        if (!($this->getConfig()->Account->force_first_scheduled_email ?? false)) {
538            // By default, a first scheduled email will be sent because the database
539            // last notification date will be initialized to a past date. If we don't
540            // want that to happen, we need to set it to a more appropriate date:
541            $savedRow->setLastNotificationSent(new DateTime());
542        }
543        $savedRow->setNotificationFrequency($schedule);
544        $savedRow->setNotificationBaseUrl($baseurl);
545        $this->getDbService(SearchServiceInterface::class)->persistEntity($savedRow);
546        return $this->redirect()->toRoute('search-history');
547    }
548
549    /**
550     * Handle search subscription request
551     *
552     * @return mixed
553     */
554    public function schedulesearchAction()
555    {
556        // Fail if saved searches or subscriptions are disabled.
557        $check = $this->serviceLocator
558            ->get(\VuFind\Config\AccountCapabilities::class);
559        if ($check->getSavedSearchSetting() === 'disabled') {
560            throw new ForbiddenException('Saved searches disabled.');
561        }
562        $scheduleOptions = $this->serviceLocator
563            ->get(\VuFind\Search\History::class)
564            ->getScheduleOptions();
565        if (empty($scheduleOptions)) {
566            throw new ForbiddenException('Scheduled searches disabled.');
567        }
568        // Fail if search ID is missing.
569        $searchId = $this->params()->fromQuery('searchid', false);
570        if (!$searchId) {
571            throw new BadRequestException('searchid missing');
572        }
573        // Require login.
574        if (!($user = $this->getUser())) {
575            return $this->forceLogin();
576        }
577        // Get the row, and fail if the current user doesn't own it.
578        $search = $this->getSearchRowSecurely($searchId, $user->getId());
579
580        // If the user has just logged in, the search might be a duplicate; if
581        // so, let's switch to the pre-existing version instead.
582        $sessId = $this->serviceLocator->get(\Laminas\Session\SessionManager::class)->getId();
583        $duplicateId = $this->isDuplicateOfSavedSearch(
584            $search,
585            $sessId,
586            $user->getId()
587        );
588        if ($duplicateId) {
589            $search->delete();
590            $this->redirect()->toRoute(
591                'myresearch-schedulesearch',
592                [],
593                ['query' => ['searchid' => $duplicateId]]
594            );
595        }
596
597        // Now fetch all the results:
598        $resultsManager = $this->serviceLocator->get(\VuFind\Search\Results\PluginManager::class);
599        $results = $search->getSearchObject()?->deminify($resultsManager);
600        if (!$results) {
601            throw new Exception("Problem getting search object from search {$search->getId()}.");
602        }
603
604        // Build the form.
605        return $this->createViewModel(
606            compact('scheduleOptions', 'search', 'results')
607        );
608    }
609
610    /**
611     * Is the provided search row a duplicate of a search that is already saved?
612     *
613     * @param ?SearchEntityInterface $rowToCheck Search row to check (if any)
614     * @param string                 $sessId     Current session ID
615     * @param int                    $userId     Current user ID
616     *
617     * @return ?int
618     */
619    protected function isDuplicateOfSavedSearch(
620        ?SearchEntityInterface $rowToCheck,
621        string $sessId,
622        int $userId
623    ): ?int {
624        if (!$rowToCheck) {
625            return null;
626        }
627        $normalizer = $this->serviceLocator->get(\VuFind\Search\SearchNormalizer::class);
628        $searchObject = $rowToCheck->getSearchObject();
629        if (!$searchObject) {
630            throw new Exception("Problem getting search object from search {$rowToCheck->getId()}.");
631        }
632        $normalized = $normalizer->normalizeMinifiedSearch($searchObject);
633        $matches = $normalizer->getSearchesMatchingNormalizedSearch(
634            $normalized,
635            $sessId,
636            $userId
637        );
638        foreach ($matches as $current) {
639            if ($current->getSaved() && $current->getId() !== $rowToCheck->getId()) {
640                return $current->getId();
641            }
642        }
643        return null;
644    }
645
646    /**
647     * Handle 'save/unsave search' request
648     *
649     * @return mixed
650     */
651    public function savesearchAction()
652    {
653        // Fail if saved searches are disabled.
654        $check = $this->serviceLocator
655            ->get(\VuFind\Config\AccountCapabilities::class);
656        if ($check->getSavedSearchSetting() === 'disabled') {
657            throw new ForbiddenException('Saved searches disabled.');
658        }
659
660        if (!($user = $this->getUser())) {
661            return $this->forceLogin();
662        }
663
664        // Check for schedule-related parameters and process them first:
665        $schedule = $this->params()->fromQuery('schedule', false);
666        $sid = $this->params()->fromQuery('searchid', false);
667        if ($schedule !== false && $sid !== false) {
668            return $this->scheduleSearch($user, $schedule, $sid);
669        }
670
671        // Check for the save / delete parameters and process them appropriately:
672        if (($id = $this->params()->fromQuery('save', false)) !== false) {
673            // If the row the user is trying to save is a duplicate of an already-
674            // saved row, we should just delete the duplicate. (This can happen if
675            // the user clicks "save" before logging in, then logs in during the
676            // save process, but has the same search already saved in their account).
677            $searchService = $this->getDbService(SearchServiceInterface::class);
678            $sessId = $this->serviceLocator
679                ->get(\Laminas\Session\SessionManager::class)->getId();
680            $rowToCheck = $searchService->getSearchByIdAndOwner($id, $sessId, $user);
681            $duplicateId = $this->isDuplicateOfSavedSearch(
682                $rowToCheck,
683                $sessId,
684                $user->getId()
685            );
686            if ($duplicateId) {
687                $rowToCheck->delete();
688                $id = $duplicateId;
689            } else {
690                $this->setSavedFlagSecurely($id, true, $user->getId());
691            }
692            $this->flashMessenger()->addMessage('search_save_success', 'success');
693        } elseif (($id = $this->params()->fromQuery('delete', false)) !== false) {
694            $this->setSavedFlagSecurely($id, false, $user->getId());
695            $this->flashMessenger()->addMessage('search_unsave_success', 'success');
696        } else {
697            throw new \Exception('Missing save and delete parameters.');
698        }
699
700        // Forward to the appropriate place:
701        if ($this->params()->fromQuery('mode') == 'history') {
702            return $this->redirect()->toRoute('search-history');
703        } else {
704            // Forward to the Search/Results action with the "saved" parameter set;
705            // this will in turn redirect the user to the appropriate results screen.
706            $this->getRequest()->getQuery()->set('saved', $id);
707            return $this->forwardTo('Search', 'Results');
708        }
709    }
710
711    /**
712     * Gather user profile data
713     *
714     * @return mixed
715     */
716    public function profileAction()
717    {
718        if (!($user = $this->getUser())) {
719            return $this->forceLogin();
720        }
721
722        // Begin building view object:
723        $view = $this->createViewModel(['user' => $user]);
724
725        $config = $this->getConfig();
726        $allowHomeLibrary = $config->Account->set_home_library ?? true;
727
728        $patron = $this->catalogLogin();
729        if (is_array($patron)) {
730            // Process home library parameter (if present and allowed):
731            $homeLibrary = $this->params()->fromPost('home_library');
732            if ($allowHomeLibrary && null !== $homeLibrary) {
733                // Note: for backward compatibility user's home library defaults to
734                // empty string indicating system default. We also allow null for
735                // "Always ask me", and the choice is encoded as ' ** ' on the form:
736                if (' ** ' === $homeLibrary) {
737                    $homeLibrary = null;
738                }
739                $this->serviceLocator->get(ILSAuthenticator::class)->updateUserHomeLibrary($user, $homeLibrary);
740                $this->flashMessenger()->addMessage('profile_update', 'success');
741            }
742
743            // Obtain user information from ILS:
744            $catalog = $this->getILS();
745            $this->addAccountBlocksToFlashMessenger($catalog, $patron);
746            $profile = $catalog->getMyProfile($patron);
747            $profile['home_library'] = $allowHomeLibrary
748                ? $user->getHomeLibrary()
749                : ($profile['home_library'] ?? '');
750            $view->profile = $profile;
751            $pickup = $defaultPickupLocation = null;
752            try {
753                $pickup = $catalog->getPickUpLocations($patron);
754                $defaultPickupLocation = $catalog->getDefaultPickUpLocation($patron);
755            } catch (\Exception $e) {
756                // Do nothing; if we're unable to load information about pickup
757                // locations, they are not supported and we should ignore them.
758            }
759
760            // Set things up differently depending on whether or not the user is
761            // allowed to set a home library.
762            if ($allowHomeLibrary) {
763                $view->pickup = $pickup;
764                $view->defaultPickupLocation = $defaultPickupLocation;
765            } elseif (!empty($pickup)) {
766                foreach ($pickup as $lib) {
767                    if ($defaultPickupLocation == $lib['locationID']) {
768                        $view->preferredLibraryDisplay = $lib['locationDisplay'];
769                        break;
770                    }
771                }
772            }
773
774            // Add proxy details if available
775            if ($catalog->checkCapability('getProxiedUsers', [$patron])) {
776                $view->proxiedUsers = $catalog->getProxiedUsers($patron);
777            }
778            if ($catalog->checkCapability('getProxyingUsers', [$patron])) {
779                $view->proxyingUsers = $catalog->getProxyingUsers($patron);
780            }
781        } else {
782            $view->patronLoginView = $patron;
783            // Turn off account menu in embedded login display:
784            $view->patronLoginView->showMenu = false;
785        }
786
787        $view->accountDeletion
788            = !empty($config->Authentication->account_deletion);
789
790        $this->addPendingEmailChangeMessage($user);
791
792        return $view;
793    }
794
795    /**
796     * Add account blocks to the flash messenger as errors.
797     * These messages are lightbox ignored.
798     *
799     * @param \VuFind\ILS\Connection $catalog Catalog connection
800     * @param array                  $patron  Patron details
801     *
802     * @return void
803     */
804    public function addAccountBlocksToFlashMessenger($catalog, $patron)
805    {
806        if (
807            $catalog->checkCapability('getAccountBlocks', compact('patron'))
808            && $blocks = $catalog->getAccountBlocks($patron)
809        ) {
810            foreach ($blocks as $block) {
811                $this->flashMessenger()->addMessage(
812                    [ 'msg' => $block, 'dataset' => [ 'lightbox-ignore' => '1' ] ],
813                    'error'
814                );
815            }
816        }
817    }
818
819    /**
820     * Catalog Login Action
821     *
822     * @return mixed
823     */
824    public function catalogloginAction()
825    {
826        $loginSettings = $this->getILSLoginSettings();
827        return $this->createViewModel($loginSettings);
828    }
829
830    /**
831     * Action for sending all of a user's saved favorites to the view
832     *
833     * @return mixed
834     */
835    public function favoritesAction()
836    {
837        // Check permission:
838        $response = $this->permission()->check('feature.Favorites', false);
839        if (is_object($response)) {
840            return $response;
841        }
842
843        // Favorites is the same as MyList, but without the list ID parameter.
844        return $this->forwardTo('MyResearch', 'MyList');
845    }
846
847    /**
848     * Delete group of records from favorites.
849     *
850     * @return mixed
851     */
852    public function deleteAction()
853    {
854        // Force login:
855        if (!($user = $this->getUser())) {
856            return $this->forceLogin();
857        }
858
859        // Get target URL for after deletion:
860        $listID = $this->params()->fromPost('listID');
861        $newUrl = empty($listID)
862            ? $this->url()->fromRoute('myresearch-favorites')
863            : $this->url()->fromRoute('userList', ['id' => $listID]);
864
865        // Fail if we have nothing to delete:
866        $ids = $this->getSelectedIds();
867
868        $actionLimit = $this->getBulkActionLimit('delete');
869        if (!is_array($ids) || empty($ids)) {
870            if ($redirect = $this->redirectToSource('error', 'bulk_noitems_advice')) {
871                return $redirect;
872            }
873        } elseif (count($ids) > $actionLimit) {
874            $errorMsg = $this->translate(
875                'bulk_limit_exceeded',
876                ['%%count%%' => count($ids), '%%limit%%' => $actionLimit],
877            );
878            if ($redirect = $this->redirectToSource('error', $errorMsg)) {
879                return $redirect;
880            }
881        } elseif ($this->formWasSubmitted()) {
882            $this->serviceLocator->get(FavoritesService::class)
883                ->deleteFavorites($ids, $listID === null ? null : (int)$listID, $user);
884            $this->flashMessenger()->addMessage('fav_delete_success', 'success');
885            return $this->redirect()->toUrl($newUrl);
886        }
887
888        // If we got this far, the operation has not been confirmed yet; show
889        // the necessary dialog box:
890        $list = empty($listID)
891            ? false
892            : $this->getDbService(UserListServiceInterface::class)->getUserListById($listID);
893        return $this->createViewModel(
894            [
895                'list' => $list, 'deleteIDS' => $ids,
896                'records' => $this->getRecordLoader()->loadBatch($ids),
897            ]
898        );
899    }
900
901    /**
902     * Delete record
903     *
904     * @param string $id     ID of record to delete
905     * @param string $source Source of record to delete
906     *
907     * @return mixed         True on success; otherwise returns a value that can
908     * be returned by the controller to forward to another action (i.e. force login)
909     */
910    public function performDeleteFavorite($id, $source)
911    {
912        // Force login:
913        if (!($user = $this->getUser())) {
914            return $this->forceLogin();
915        }
916
917        // Load/check incoming parameters:
918        $listID = $this->params()->fromRoute('id');
919        $listID = empty($listID) ? null : $listID;
920        if (empty($id)) {
921            throw new \Exception('Cannot delete empty ID!');
922        }
923
924        // Perform delete and send appropriate flash message:
925        $favoritesService = $this->serviceLocator->get(FavoritesService::class);
926        if (null !== $listID) {
927            // ...Specific List
928            $list = $this->getDbService(UserListServiceInterface::class)->getUserListById($listID);
929            $favoritesService->removeListResourcesById($list, $user, [$id], $source);
930            $this->flashMessenger()->addMessage('Item removed from list', 'success');
931        } else {
932            // ...All Saved Items
933            $favoritesService->removeUserResourcesById($user, [$id], $source);
934            $this->flashMessenger()->addMessage('Item removed from favorites', 'success');
935        }
936
937        // All done -- return true to indicate success.
938        return true;
939    }
940
941    /**
942     * Process the submission of the edit favorite form.
943     *
944     * @param UserEntityInterface               $user   Logged-in user
945     * @param \VuFind\RecordDriver\AbstractBase $driver Record driver for favorite
946     * @param int                               $listID List being edited (null
947     * if editing all favorites)
948     *
949     * @return object
950     */
951    protected function processEditSubmit(UserEntityInterface $user, $driver, $listID)
952    {
953        $lists = $this->params()->fromPost('lists', []);
954        $tagsService = $this->serviceLocator->get(\VuFind\Tags\TagsService::class);
955        $favorites = $this->serviceLocator
956            ->get(\VuFind\Favorites\FavoritesService::class);
957        $didSomething = false;
958        foreach ($lists as $list) {
959            $tags = $this->params()->fromPost('tags' . $list);
960            $favorites->save(
961                [
962                    'list'  => $list,
963                    'mytags'  => $tagsService->parse($tags),
964                    'notes' => $this->params()->fromPost('notes' . $list),
965                ],
966                $user,
967                $driver
968            );
969            $didSomething = true;
970        }
971        // add to a new list?
972        $addToList = $this->params()->fromPost('addToList');
973        if ($addToList > -1) {
974            $didSomething = true;
975            $favorites->save(['list' => $addToList], $user, $driver);
976        }
977        if ($didSomething) {
978            $this->flashMessenger()->addMessage('edit_list_success', 'success');
979        }
980
981        $newUrl = null === $listID
982            ? $this->url()->fromRoute('myresearch-favorites')
983            : $this->url()->fromRoute('userList', ['id' => $listID]);
984        return $this->redirect()->toUrl($newUrl);
985    }
986
987    /**
988     * Edit record
989     *
990     * @return mixed
991     */
992    public function editAction()
993    {
994        // Force login:
995        if (!($user = $this->getUser())) {
996            return $this->forceLogin();
997        }
998
999        // Get current record (and, if applicable, selected list ID) for convenience:
1000        $id = $this->params()->fromPost('id', $this->params()->fromQuery('id'));
1001        $source = $this->params()->fromPost(
1002            'source',
1003            $this->params()->fromQuery('source', DEFAULT_SEARCH_BACKEND)
1004        );
1005        $driver = $this->getRecordLoader()->load($id, $source, true);
1006        $listID = $this->params()->fromPost(
1007            'list_id',
1008            $this->params()->fromQuery('list_id', null)
1009        );
1010
1011        // Process save action if necessary:
1012        if ($this->formWasSubmitted()) {
1013            return $this->processEditSubmit($user, $driver, $listID);
1014        }
1015
1016        // Get saved favorites for selected list (or all lists if $listID is null)
1017        $userResourceService = $this->getDbService(UserResourceServiceInterface::class);
1018        $userResources = $userResourceService->getFavoritesForRecord($id, $source, $listID, $user);
1019        $savedData = [];
1020        $favoritesService = $this->serviceLocator->get(FavoritesService::class);
1021        foreach ($userResources as $current) {
1022            // There should always be list data based on the way we retrieve this result, but
1023            // check just to be on the safe side.
1024            if ($currentList = $current->getUserList()) {
1025                $savedData[] = [
1026                    'listId' => $currentList->getId(),
1027                    'listTitle' => $currentList->getTitle(),
1028                    'notes' => $current->getNotes(),
1029                    'tags' => $favoritesService->getTagStringForEditing($user, $currentList, $id, $source),
1030                ];
1031            }
1032        }
1033
1034        // In order to determine which lists contain the requested item, we may
1035        // need to do an extra database lookup if the previous lookup was limited
1036        // to a particular list ID:
1037        $containingLists = [];
1038        if (!empty($listID)) {
1039            $userResources = $userResourceService->getFavoritesForRecord($id, $source, null, $user);
1040        }
1041        foreach ($userResources as $current) {
1042            if ($currentList = $current->getUserList()) {
1043                $containingLists[] = $currentList->getId();
1044            }
1045        }
1046
1047        // Send non-containing lists to the view for user selection:
1048        $userLists = $this->getDbService(UserListServiceInterface::class)->getUserListsByUser($user);
1049        $lists = [];
1050        foreach ($userLists as $userList) {
1051            if (!in_array($userList->getId(), $containingLists)) {
1052                $lists[$userList->getId()] = $userList->getTitle();
1053            }
1054        }
1055
1056        return $this->createViewModel(
1057            compact('driver', 'lists', 'savedData', 'listID')
1058        );
1059    }
1060
1061    /**
1062     * Confirm a request to delete a favorite item.
1063     *
1064     * @param string $id     ID of record to delete
1065     * @param string $source Source of record to delete
1066     *
1067     * @return mixed
1068     */
1069    protected function confirmDeleteFavorite($id, $source)
1070    {
1071        // Normally list ID is found in the route match, but in lightbox context it
1072        // may sometimes be a GET parameter. We must cover both cases.
1073        $listID = $this->params()->fromRoute('id', $this->params()->fromQuery('id'));
1074        if (empty($listID)) {
1075            $url = $this->url()->fromRoute('myresearch-favorites');
1076        } else {
1077            $url = $this->url()->fromRoute('userList', ['id' => $listID]);
1078        }
1079        return $this->confirm(
1080            'confirm_delete_brief',
1081            $url,
1082            $url,
1083            'confirm_delete',
1084            ['delete' => $id, 'source' => $source]
1085        );
1086    }
1087
1088    /**
1089     * Send user's saved favorites from a particular list to the view
1090     *
1091     * @return mixed
1092     */
1093    public function mylistAction()
1094    {
1095        // Fail if lists are disabled:
1096        if (!$this->listsEnabled()) {
1097            throw new ForbiddenException('Lists disabled');
1098        }
1099
1100        // Check for "delete item" request; parameter may be in GET or POST depending
1101        // on calling context.
1102        $deleteId = $this->params()->fromPost(
1103            'delete',
1104            $this->params()->fromQuery('delete')
1105        );
1106        if ($deleteId) {
1107            $deleteSource = $this->params()->fromPost(
1108                'source',
1109                $this->params()->fromQuery('source', DEFAULT_SEARCH_BACKEND)
1110            );
1111            // If the user already confirmed the operation, perform the delete now;
1112            // otherwise prompt for confirmation:
1113            $confirm = $this->params()->fromPost(
1114                'confirm',
1115                $this->params()->fromQuery('confirm')
1116            );
1117            if ($confirm) {
1118                $success = $this->performDeleteFavorite($deleteId, $deleteSource);
1119                if ($success !== true) {
1120                    return $success;
1121                }
1122            } else {
1123                return $this->confirmDeleteFavorite($deleteId, $deleteSource);
1124            }
1125        }
1126
1127        // If we got this far, we just need to display the favorites:
1128        try {
1129            $runner = $this->serviceLocator->get(\VuFind\Search\SearchRunner::class);
1130
1131            // We want to merge together GET, POST and route parameters to
1132            // initialize our search object:
1133            $request = $this->getRequest()->getQuery()->toArray()
1134                + $this->getRequest()->getPost()->toArray()
1135                + ['id' => $this->params()->fromRoute('id')];
1136
1137            // Set up listener for recommendations:
1138            $rManager = $this->serviceLocator
1139                ->get(\VuFind\Recommend\PluginManager::class);
1140            $setupCallback = function ($runner, $params, $searchId) use ($rManager) {
1141                $listener = new RecommendListener($rManager, $searchId);
1142                $listener->setConfig(
1143                    $params->getOptions()->getRecommendationSettings()
1144                );
1145                $listener->attach($runner->getEventManager()->getSharedManager());
1146            };
1147
1148            $results = $runner->run($request, 'Favorites', $setupCallback);
1149            $listTags = [];
1150
1151            if ($this->listTagsEnabled()) {
1152                if ($list = $results->getListObject()) {
1153                    $tags = $this->serviceLocator->get(TagsService::class)->getListTags($list, $list->getUser());
1154                    foreach ($tags as $tag) {
1155                        $listTags[$tag['id']] = $tag['tag'];
1156                    }
1157                }
1158            }
1159            return $this->createViewModel(
1160                [
1161                    'params' => $results->getParams(), 'results' => $results,
1162                    'listTags' => $listTags,
1163                ]
1164            );
1165        } catch (ListPermissionException $e) {
1166            if (!$this->getUser()) {
1167                return $this->forceLogin();
1168            }
1169            throw $e;
1170        }
1171    }
1172
1173    /**
1174     * Process the "edit list" submission.
1175     *
1176     * @param UserEntityInterface     $user Logged in user
1177     * @param UserListEntityInterface $list List being created/edited
1178     *
1179     * @return object|bool                  Response object if redirect is
1180     * needed, false if form needs to be redisplayed.
1181     */
1182    protected function processEditList(UserEntityInterface $user, $list)
1183    {
1184        // Process form within a try..catch so we can handle errors appropriately:
1185        try {
1186            $favoritesService = $this->serviceLocator->get(FavoritesService::class);
1187            $finalId = $favoritesService->updateListFromRequest($list, $user, $this->getRequest()->getPost());
1188
1189            // If the user is in the process of saving a record, send them back
1190            // to the save screen; otherwise, send them back to the list they
1191            // just edited.
1192            $recordId = $this->params()->fromQuery('recordId');
1193            $recordSource
1194                = $this->params()->fromQuery('recordSource', DEFAULT_SEARCH_BACKEND);
1195            if (!empty($recordId)) {
1196                $details = $this->getRecordRouter()->getActionRouteDetails(
1197                    $recordSource . '|' . $recordId,
1198                    'Save'
1199                );
1200                return $this->redirect()->toRoute(
1201                    $details['route'],
1202                    $details['params']
1203                );
1204            }
1205
1206            // Similarly, if the user is in the process of bulk-saving records,
1207            // send them back to the appropriate place in the cart.
1208            $bulkIds = $this->params()->fromPost(
1209                'ids',
1210                $this->params()->fromQuery('ids', [])
1211            );
1212            if (!empty($bulkIds)) {
1213                $params = [];
1214                foreach ($bulkIds as $id) {
1215                    $params[] = urlencode('ids[]') . '=' . urlencode($id);
1216                }
1217                $saveUrl = $this->url()->fromRoute('cart-save');
1218                $saveUrl .= (!str_contains($saveUrl, '?')) ? '?' : '&';
1219                return $this->redirect()
1220                    ->toUrl($saveUrl . implode('&', $params));
1221            }
1222
1223            return $this->redirect()->toRoute('userList', ['id' => $finalId]);
1224        } catch (ListPermissionException | MissingFieldException $e) {
1225            $this->flashMessenger()->addMessage($e->getMessage(), 'error');
1226            return false;
1227        } catch (LoginRequiredException $e) {
1228            return $this->forceLogin();
1229        }
1230    }
1231
1232    /**
1233     * Send user's saved favorites from a particular list to the edit view
1234     *
1235     * @return mixed
1236     */
1237    public function editlistAction()
1238    {
1239        // Fail if lists are disabled:
1240        if (!$this->listsEnabled()) {
1241            throw new ForbiddenException('Lists disabled');
1242        }
1243
1244        // User must be logged in to edit list:
1245        $user = $this->getUser();
1246        if (!$user) {
1247            return $this->forceLogin();
1248        }
1249
1250        // Is this a new list or an existing list?  Handle the special 'NEW' value
1251        // of the ID parameter:
1252        $id = $this->params()->fromRoute('id', $this->params()->fromQuery('id'));
1253        $newList = ($id == 'NEW');
1254        // If this is a new list, use the FavoritesService to pre-populate some values in
1255        // a fresh object; if it's an existing list, we can just fetch from the database.
1256        $favoritesService = $this->serviceLocator->get(FavoritesService::class);
1257        $list = $newList
1258            ? $favoritesService->createListForUser($user)
1259            : $this->getDbService(UserListServiceInterface::class)->getUserListById($id);
1260
1261        // Make sure the user isn't fishing for other people's lists:
1262        if (!$newList && !$favoritesService->userCanEditList($user, $list)) {
1263            throw new ListPermissionException('Access denied.');
1264        }
1265
1266        // Process form submission:
1267        if ($this->formWasSubmitted()) {
1268            if ($redirect = $this->processEditList($user, $list)) {
1269                return $redirect;
1270            }
1271        }
1272
1273        $listTags = null;
1274        if ($this->listTagsEnabled() && !$newList) {
1275            $tagsService = $this->serviceLocator->get(TagsService::class);
1276            $listTags = $favoritesService
1277                ->formatTagStringForEditing($tagsService->getListTags($list, $list->getUser()));
1278        }
1279        // Send the list to the view:
1280        return $this->createViewModel(
1281            [
1282                'list' => $list,
1283                'newList' => $newList,
1284                'listTags' => $listTags,
1285            ]
1286        );
1287    }
1288
1289    /**
1290     * Creates a message that the verification email has been sent to the user's
1291     * mail address.
1292     *
1293     * @return mixed
1294     */
1295    public function emailNotVerifiedAction()
1296    {
1297        if ($this->params()->fromQuery('reverify')) {
1298            $change = false;
1299            // Case 1: new user:
1300            $user = $this->getDbService(UserServiceInterface::class)
1301                ->getUserByUsername($this->getUserVerificationContainer()->user);
1302            // Case 2: pending email change:
1303            if (!$user) {
1304                $user = $this->getUser();
1305                if ($user && $user->getPendingEmail()) {
1306                    $change = true;
1307                }
1308            }
1309            $this->sendVerificationEmail($user, $change);
1310        } else {
1311            $this->flashMessenger()->addMessage('verification_email_sent', 'info');
1312        }
1313        return $this->createViewModel();
1314    }
1315
1316    /**
1317     * Creates a confirmation box to delete or not delete the current list
1318     *
1319     * @return mixed
1320     */
1321    public function deletelistAction()
1322    {
1323        // Fail if lists are disabled:
1324        if (!$this->listsEnabled()) {
1325            throw new ForbiddenException('Lists disabled');
1326        }
1327
1328        // Get requested list ID:
1329        $listID = $this->params()
1330            ->fromPost('listID', $this->params()->fromQuery('listID'));
1331
1332        // Have we confirmed this?
1333        $confirm = $this->params()->fromPost(
1334            'confirm',
1335            $this->params()->fromQuery('confirm')
1336        );
1337        if ($confirm) {
1338            try {
1339                $list = $this->getDbService(UserListServiceInterface::class)->getUserListById($listID);
1340                $this->serviceLocator->get(FavoritesService::class)->destroyList($list, $this->getUser());
1341
1342                // Success Message
1343                $this->flashMessenger()->addMessage('fav_list_delete', 'success');
1344            } catch (LoginRequiredException | ListPermissionException $e) {
1345                if (!$this->getUser()) {
1346                    return $this->forceLogin();
1347                }
1348                // Logged in? Then we have to rethrow the exception!
1349                throw $e;
1350            }
1351            // Redirect to MyResearch home
1352            return $this->redirect()->toRoute('myresearch-favorites');
1353        }
1354
1355        // If we got this far, we must display a confirmation message:
1356        return $this->confirm(
1357            'confirm_delete_list_brief',
1358            $this->url()->fromRoute('myresearch-deletelist'),
1359            $this->url()->fromRoute('userList', ['id' => $listID]),
1360            'confirm_delete_list_text',
1361            ['listID' => $listID]
1362        );
1363    }
1364
1365    /**
1366     * Send list of holds to view
1367     *
1368     * @return mixed
1369     *
1370     * @deprecated
1371     */
1372    public function holdsAction()
1373    {
1374        return $this->redirect()->toRoute('holds-list');
1375    }
1376
1377    /**
1378     * Send list of storage retrieval requests to view
1379     *
1380     * @return mixed
1381     */
1382    public function storageRetrievalRequestsAction()
1383    {
1384        // Stop now if the user does not have valid catalog credentials available:
1385        if (!is_array($patron = $this->catalogLogin())) {
1386            return $patron;
1387        }
1388
1389        // Connect to the ILS:
1390        $catalog = $this->getILS();
1391
1392        // Process cancel requests if necessary:
1393        $cancelSRR = $catalog->checkFunction(
1394            'cancelStorageRetrievalRequests',
1395            compact('patron')
1396        );
1397        $view = $this->createViewModel();
1398        $view->cancelResults = $cancelSRR
1399            ? $this->storageRetrievalRequests()->cancelStorageRetrievalRequests(
1400                $catalog,
1401                $patron
1402            )
1403            : [];
1404        // If we need to confirm
1405        if (!is_array($view->cancelResults)) {
1406            return $view->cancelResults;
1407        }
1408
1409        // By default, assume we will not need to display a cancel form:
1410        $view->cancelForm = false;
1411
1412        // Get request details:
1413        $result = $catalog->getMyStorageRetrievalRequests($patron);
1414        $driversNeeded = [];
1415        $this->storageRetrievalRequests()->resetValidation();
1416        foreach ($result as $current) {
1417            // Add cancel details if appropriate:
1418            $current = $this->storageRetrievalRequests()->addCancelDetails(
1419                $catalog,
1420                $current,
1421                $cancelSRR,
1422                $patron
1423            );
1424            if (
1425                $cancelSRR
1426                && $cancelSRR['function'] != 'getCancelStorageRetrievalRequestLink'
1427                && isset($current['cancel_details'])
1428            ) {
1429                // Enable cancel form if necessary:
1430                $view->cancelForm = true;
1431            }
1432
1433            $driversNeeded[] = $current;
1434        }
1435
1436        // Get List of PickUp Libraries based on patron's home library
1437        try {
1438            $view->pickup = $catalog->getPickUpLocations($patron);
1439        } catch (\Exception $e) {
1440            // Do nothing; if we're unable to load information about pickup
1441            // locations, they are not supported and we should ignore them.
1442        }
1443
1444        $view->recordList = $this->ilsRecords()->getDrivers($driversNeeded);
1445        $view->accountStatus = $this->ilsRecords()
1446            ->collectRequestStats($view->recordList);
1447        return $view;
1448    }
1449
1450    /**
1451     * Send list of ill requests to view
1452     *
1453     * @return mixed
1454     */
1455    public function illRequestsAction()
1456    {
1457        // Stop now if the user does not have valid catalog credentials available:
1458        if (!is_array($patron = $this->catalogLogin())) {
1459            return $patron;
1460        }
1461
1462        // Connect to the ILS:
1463        $catalog = $this->getILS();
1464
1465        // Process cancel requests if necessary:
1466        $cancelStatus = $catalog->checkFunction(
1467            'cancelILLRequests',
1468            compact('patron')
1469        );
1470        $view = $this->createViewModel();
1471        $view->cancelResults = $cancelStatus
1472            ? $this->ILLRequests()->cancelILLRequests(
1473                $catalog,
1474                $patron
1475            )
1476            : [];
1477        // If we need to confirm
1478        if (!is_array($view->cancelResults)) {
1479            return $view->cancelResults;
1480        }
1481
1482        // By default, assume we will not need to display a cancel form:
1483        $view->cancelForm = false;
1484
1485        // Get request details:
1486        $result = $catalog->getMyILLRequests($patron);
1487        $driversNeeded = [];
1488        $this->ILLRequests()->resetValidation();
1489        foreach ($result as $current) {
1490            // Add cancel details if appropriate:
1491            $current = $this->ILLRequests()->addCancelDetails(
1492                $catalog,
1493                $current,
1494                $cancelStatus,
1495                $patron
1496            );
1497            if (
1498                $cancelStatus
1499                && $cancelStatus['function'] != 'getCancelILLRequestLink'
1500                && isset($current['cancel_details'])
1501            ) {
1502                // Enable cancel form if necessary:
1503                $view->cancelForm = true;
1504            }
1505
1506            $driversNeeded[] = $current;
1507        }
1508
1509        $view->recordList = $this->ilsRecords()->getDrivers($driversNeeded);
1510        $view->accountStatus = $this->ilsRecords()
1511            ->collectRequestStats($view->recordList);
1512        return $view;
1513    }
1514
1515    /**
1516     * Send list of checked out books to view
1517     *
1518     * @return mixed
1519     */
1520    public function checkedoutAction()
1521    {
1522        // Stop now if the user does not have valid catalog credentials available:
1523        if (!is_array($patron = $this->catalogLogin())) {
1524            return $patron;
1525        }
1526
1527        // Connect to the ILS:
1528        $catalog = $this->getILS();
1529
1530        // Display account blocks, if any:
1531        $this->addAccountBlocksToFlashMessenger($catalog, $patron);
1532
1533        // Get the current renewal status and process renewal form, if necessary:
1534        $renewStatus = $catalog->checkFunction('Renewals', compact('patron'));
1535        $renewResult = $renewStatus
1536            ? $this->renewals()->processRenewals(
1537                $this->getRequest()->getPost(),
1538                $catalog,
1539                $patron,
1540                $this->serviceLocator->get(CsrfInterface::class)
1541            )
1542            : [];
1543
1544        // By default, assume we will not need to display a renewal form:
1545        $renewForm = false;
1546
1547        // Get paging setup:
1548        $config = $this->getConfig();
1549        $pageSize = $config->Catalog->checked_out_page_size ?? 50;
1550        $pageOptions = $this->getPaginationHelper()->getOptions(
1551            (int)$this->params()->fromQuery('page', 1),
1552            $this->params()->fromQuery('sort'),
1553            $pageSize,
1554            $catalog->checkFunction('getMyTransactions', $patron)
1555        );
1556
1557        // Get checked out item details:
1558        $result = $catalog->getMyTransactions($patron, $pageOptions['ilsParams']);
1559
1560        // Build paginator if needed:
1561        $paginator = $this->getPaginationHelper()->getPaginator(
1562            $pageOptions,
1563            $result['count'],
1564            $result['records']
1565        );
1566        if ($paginator) {
1567            $pageStart = $paginator->getAbsoluteItemNumber(1) - 1;
1568            $pageEnd = $paginator->getAbsoluteItemNumber($pageOptions['limit']) - 1;
1569        } else {
1570            $pageStart = 0;
1571            $pageEnd = $result['count'];
1572        }
1573
1574        // If the results are not paged in the ILS, collect up to date stats for ajax
1575        // account notifications:
1576        if (
1577            !empty($config->Authentication->enableAjax)
1578            && (!$pageOptions['ilsPaging'] || !$paginator
1579            || $result['count'] <= $pageSize)
1580        ) {
1581            $accountStatus = $this->getTransactionSummary($result['records']);
1582        } else {
1583            $accountStatus = null;
1584        }
1585
1586        $driversNeeded = $hiddenTransactions = [];
1587        foreach ($result['records'] as $i => $current) {
1588            // Add renewal details if appropriate:
1589            $current = $this->renewals()->addRenewDetails(
1590                $catalog,
1591                $current,
1592                $renewStatus
1593            );
1594            if (
1595                $renewStatus && !isset($current['renew_link'])
1596                && $current['renewable']
1597            ) {
1598                // Enable renewal form if necessary:
1599                $renewForm = true;
1600            }
1601
1602            // Build record drivers (only for the current visible page):
1603            if ($pageOptions['ilsPaging'] || ($i >= $pageStart && $i <= $pageEnd)) {
1604                $driversNeeded[] = $current;
1605            } else {
1606                $hiddenTransactions[] = $current;
1607            }
1608        }
1609
1610        $transactions = $this->ilsRecords()->getDrivers($driversNeeded);
1611
1612        $displayItemBarcode
1613            = !empty($config->Catalog->display_checked_out_item_barcode);
1614
1615        $ilsPaging = $pageOptions['ilsPaging'];
1616        $sortList = $pageOptions['sortList'];
1617        $params = $pageOptions['ilsParams'];
1618        return $this->createViewModel(
1619            compact(
1620                'transactions',
1621                'renewForm',
1622                'renewResult',
1623                'paginator',
1624                'ilsPaging',
1625                'hiddenTransactions',
1626                'displayItemBarcode',
1627                'sortList',
1628                'params',
1629                'accountStatus'
1630            )
1631        );
1632    }
1633
1634    /**
1635     * Send list of historic loans to view
1636     *
1637     * @return mixed
1638     */
1639    public function historicloansAction()
1640    {
1641        return $this->redirect()->toRoute('checkouts-history');
1642    }
1643
1644    /**
1645     * Send list of fines to view
1646     *
1647     * @return mixed
1648     */
1649    public function finesAction()
1650    {
1651        // Stop now if the user does not have valid catalog credentials available:
1652        if (!is_array($patron = $this->catalogLogin())) {
1653            return $patron;
1654        }
1655
1656        // Connect to the ILS:
1657        $catalog = $this->getILS();
1658
1659        // Get fine details:
1660        $result = $catalog->getMyFines($patron);
1661        $fines = [];
1662        $driversNeeded = [];
1663        foreach ($result as $i => $row) {
1664            // If we have an id, add it to list of record drivers to load:
1665            if ($row['id'] ?? false) {
1666                $driversNeeded[$i] = [
1667                    'id' => $row['id'],
1668                    'source' => $row['source'] ?? DEFAULT_SEARCH_BACKEND,
1669                ];
1670            }
1671            // Store by original index so that we can access it when loading record
1672            // drivers:
1673            $fines[$i] = $row;
1674        }
1675
1676        if ($driversNeeded) {
1677            $recordLoader = $this->serviceLocator->get(\VuFind\Record\Loader::class);
1678            $drivers = $recordLoader->loadBatch($driversNeeded, true);
1679            foreach ($drivers as $i => $driver) {
1680                $fines[$i]['driver'] = $driver;
1681                if (empty($fines[$i]['title'])) {
1682                    $fines[$i]['title'] = $driver->getShortTitle();
1683                }
1684            }
1685        }
1686
1687        // Clean up array keys:
1688        $fines = array_values($fines);
1689
1690        // Collect up to date stats for ajax account notifications:
1691        if (!empty($this->getConfig()->Authentication->enableAjax)) {
1692            $accountStatus = $this->getFineSummary(
1693                $fines,
1694                $this->serviceLocator->get(\VuFind\Service\CurrencyFormatter::class)
1695            );
1696        } else {
1697            $accountStatus = null;
1698        }
1699
1700        return $this->createViewModel(compact('fines', 'accountStatus'));
1701    }
1702
1703    /**
1704     * Convenience method to get a session initiator URL. Returns false if not
1705     * applicable.
1706     *
1707     * @return string|bool
1708     */
1709    protected function getSessionInitiator()
1710    {
1711        $url = $this->getServerUrl('myresearch-home');
1712        return $this->getAuthManager()->getSessionInitiator($url);
1713    }
1714
1715    /**
1716     * Send account recovery email
1717     *
1718     * @return mixed
1719     */
1720    public function recoverAction()
1721    {
1722        // Make sure we're configured to do this
1723        $this->setUpAuthenticationFromRequest();
1724        if (!$this->getAuthManager()->supportsRecovery()) {
1725            $this->flashMessenger()->addMessage('recovery_disabled', 'error');
1726            return $this->redirect()->toRoute('myresearch-home');
1727        }
1728        if ($this->getUser()) {
1729            return $this->redirect()->toRoute('myresearch-home');
1730        }
1731        // Database
1732        $userService = $this->getDbService(UserServiceInterface::class);
1733        $user = false;
1734        // Check if we have a submitted form, and use the information
1735        // to get the user's information
1736        if ($email = $this->params()->fromPost('email')) {
1737            $user = $userService->getUserByEmail($email);
1738        } elseif ($username = $this->params()->fromPost('username')) {
1739            $user = $userService->getUserByUsername($username);
1740        }
1741        $view = $this->createViewModel();
1742        $view->useCaptcha = $this->captcha()->active('passwordRecovery');
1743        // If we have a submitted form
1744        if ($this->formWasSubmitted(useCaptcha: $view->useCaptcha)) {
1745            if ($user) {
1746                $this->sendRecoveryEmail($user, $this->getConfig());
1747            } else {
1748                $this->flashMessenger()
1749                    ->addMessage('recovery_user_not_found', 'error');
1750            }
1751        }
1752        return $view;
1753    }
1754
1755    /**
1756     * Helper function for recoverAction
1757     *
1758     * @param UserEntityInterface $user   User object we're recovering
1759     * @param \VuFind\Config      $config Configuration object
1760     *
1761     * @return void (sends email or adds error message)
1762     */
1763    protected function sendRecoveryEmail(UserEntityInterface $user, $config)
1764    {
1765        // If we can't find a user
1766        if (!$user) {
1767            $this->flashMessenger()->addMessage('recovery_user_not_found', 'error');
1768        } else {
1769            // Make sure we've waited long enough
1770            $hashtime = $this->getHashAge($user->getVerifyHash());
1771            $recoveryInterval = $config->Authentication->recover_interval ?? 60;
1772            if (time() - $hashtime < $recoveryInterval) {
1773                $this->flashMessenger()->addMessage('recovery_too_soon', 'error');
1774            } else {
1775                // Attempt to send the email
1776                try {
1777                    // Create a fresh hash
1778                    $this->getAuthManager()->updateUserVerifyHash($user);
1779                    $config = $this->getConfig();
1780                    $renderer = $this->getViewRenderer();
1781                    $method = $this->getAuthManager()->getAuthMethod();
1782                    // Custom template for emails (text-only)
1783                    $message = $renderer->render(
1784                        'Email/recover-password.phtml',
1785                        [
1786                            'library' => $config->Site->title,
1787                            'url' => $this->getServerUrl('myresearch-verify')
1788                                . '?hash='
1789                                . $user->getVerifyHash() . '&auth_method=' . $method,
1790                        ]
1791                    );
1792                    $this->serviceLocator->get(Mailer::class)->send(
1793                        $user->getEmail(),
1794                        $config->Site->email,
1795                        $this->translate('recovery_email_subject'),
1796                        $message
1797                    );
1798                    $this->flashMessenger()
1799                        ->addMessage('recovery_email_sent', 'success');
1800                } catch (MailException $e) {
1801                    $this->flashMessenger()->addMessage($e->getDisplayMessage(), 'error');
1802                }
1803            }
1804        }
1805    }
1806
1807    /**
1808     * Send a verify email message for the first time (only if the user does not
1809     * already have a hash).
1810     *
1811     * @param UserEntityInterface $user User object we're recovering
1812     *
1813     * @return void (sends email or adds error message)
1814     */
1815    protected function sendFirstVerificationEmail(UserEntityInterface $user)
1816    {
1817        if (!$user->getVerifyHash()) {
1818            $this->sendVerificationEmail($user);
1819        }
1820    }
1821
1822    /**
1823     * When a request to change a user's email address has been received, we should
1824     * send a notification to the old email address for the user's information.
1825     *
1826     * @param UserEntityInterface $user     User whose email address is being changed
1827     * @param string              $newEmail New email address
1828     *
1829     * @return void (sends email or adds error message)
1830     */
1831    protected function sendChangeNotificationEmail($user, $newEmail)
1832    {
1833        // Don't send the notification if the existing email is not valid:
1834        $validator = new \Laminas\Validator\EmailAddress();
1835        if (!$validator->isValid($user->getEmail())) {
1836            return;
1837        }
1838
1839        $config = $this->getConfig();
1840        $renderer = $this->getViewRenderer();
1841        // Custom template for emails (text-only)
1842        $message = $renderer->render(
1843            'Email/notify-email-change.phtml',
1844            [
1845                'library' => $config->Site->title,
1846                'url' => $this->getServerUrl('home'),
1847                'email' => $config->Site->email,
1848                'newEmail' => $newEmail,
1849            ]
1850        );
1851        // If the user is setting up a new account, use the main email
1852        // address; if they have a pending address change, use that.
1853        $this->serviceLocator->get(Mailer::class)->send(
1854            $user->getEmail(),
1855            $config->Site->email,
1856            $this->translate('change_notification_email_subject'),
1857            $message
1858        );
1859    }
1860
1861    /**
1862     * Send a verify email message.
1863     *
1864     * @param ?UserEntityInterface $user   User object we're recovering
1865     * @param bool                 $change Is the user changing their email (true)
1866     * or setting up a new account (false).
1867     *
1868     * @return void (sends email or adds error message)
1869     */
1870    protected function sendVerificationEmail($user, $change = false)
1871    {
1872        // If we can't find a user
1873        if (null == $user) {
1874            $this->flashMessenger()
1875                ->addMessage('verification_user_not_found', 'error');
1876        } else {
1877            // Make sure we've waited long enough
1878            $hashtime = $this->getHashAge($user->getVerifyHash());
1879            $recoveryInterval = $this->getConfig()->Authentication->recover_interval
1880                ?? 60;
1881            if (time() - $hashtime < $recoveryInterval && !$change) {
1882                $this->flashMessenger()
1883                    ->addMessage('verification_too_soon', 'error');
1884            } else {
1885                // Attempt to send the email
1886                try {
1887                    // Create a fresh hash
1888                    $this->getAuthManager()->updateUserVerifyHash($user);
1889                    $config = $this->getConfig();
1890                    $renderer = $this->getViewRenderer();
1891                    // Custom template for emails (text-only)
1892                    $message = $renderer->render(
1893                        'Email/verify-email.phtml',
1894                        [
1895                            'library' => $config->Site->title,
1896                            'url' => $this->getServerUrl('myresearch-verifyemail')
1897                                . '?hash=' . urlencode($user->getVerifyHash()),
1898                        ]
1899                    );
1900                    // If the user is setting up a new account, use the main email
1901                    // address; if they have a pending address change, use that.
1902                    $to = ($pending = $user->getPendingEmail()) ? $pending : $user->getEmail();
1903                    $this->serviceLocator->get(Mailer::class)->send(
1904                        $to,
1905                        $config->Site->email,
1906                        $this->translate('verification_email_subject'),
1907                        $message
1908                    );
1909                    $flashMessage = $change
1910                        ? 'verification_email_change_sent'
1911                        : 'verification_email_sent';
1912                    $this->flashMessenger()->addMessage($flashMessage, 'info');
1913                    // If this is an email change, send a notification to the old
1914                    // email address as well.
1915                    if ($change) {
1916                        $this->sendChangeNotificationEmail($user, $to);
1917                    }
1918                } catch (MailException $e) {
1919                    $this->flashMessenger()->addMessage($e->getDisplayMessage(), 'error');
1920                }
1921            }
1922        }
1923    }
1924
1925    /**
1926     * Receive a hash and display the new password form if it's valid
1927     *
1928     * @return mixed
1929     */
1930    public function verifyAction()
1931    {
1932        // If we have a submitted form
1933        if ($hash = $this->params()->fromQuery('hash')) {
1934            $hashtime = $this->getHashAge($hash);
1935            $config = $this->getConfig();
1936            // Check if hash is expired
1937            $hashLifetime = $config->Authentication->recover_hash_lifetime
1938                ?? 1209600; // Two weeks
1939            if (time() - $hashtime > $hashLifetime) {
1940                $this->flashMessenger()
1941                    ->addMessage('recovery_expired_hash', 'error');
1942                return $this->forwardTo('MyResearch', 'Login');
1943            } else {
1944                // If the hash is valid, forward user to create new password
1945                // Also treat email address as verified
1946                if ($user = $this->getDbService(UserServiceInterface::class)->getUserByVerifyHash($hash)) {
1947                    $user->setEmailVerified(new DateTime());
1948                    $this->getDbService(UserServiceInterface::class)->persistEntity($user);
1949                    $this->setUpAuthenticationFromRequest();
1950                    $view = $this->createViewModel();
1951                    $view->auth_method = $this->getAuthManager()->getAuthMethod();
1952                    $view->hash = $hash;
1953                    $view->username = $user->getUsername();
1954                    $view->useCaptcha = $this->captcha()->active('changePassword');
1955                    $view->passwordPolicy = $this->getAuthManager()
1956                        ->getPasswordPolicy();
1957                    $view->setTemplate('myresearch/newpassword');
1958                    return $view;
1959                }
1960            }
1961        }
1962        $this->flashMessenger()->addMessage('recovery_invalid_hash', 'error');
1963        return $this->forwardTo('MyResearch', 'Login');
1964    }
1965
1966    /**
1967     * Receive a hash and display the new password form if it's valid
1968     *
1969     * @return mixed
1970     */
1971    public function verifyEmailAction()
1972    {
1973        // If we have a submitted form
1974        if ($hash = $this->params()->fromQuery('hash')) {
1975            $hashtime = $this->getHashAge($hash);
1976            $config = $this->getConfig();
1977            // Check if hash is expired
1978            $hashLifetime = $config->Authentication->recover_hash_lifetime
1979                ?? 1209600; // Two weeks
1980            if (time() - $hashtime > $hashLifetime) {
1981                $this->flashMessenger()
1982                    ->addMessage('recovery_expired_hash', 'error');
1983                return $this->forwardTo('MyResearch', 'Profile');
1984            } else {
1985                // If the hash is valid, store validation in DB and forward to login
1986                if ($user = $this->getDbService(UserServiceInterface::class)->getUserByVerifyHash($hash)) {
1987                    // Apply pending email address change, if applicable:
1988                    if ($pending = $user->getPendingEmail()) {
1989                        $this->getDbService(UserServiceInterface::class)
1990                            ->updateUserEmail($user, $pending, true);
1991                        $user->setPendingEmail('');
1992                    }
1993                    $user->setEmailVerified(new DateTime());
1994                    $this->getDbService(UserServiceInterface::class)->persistEntity($user);
1995
1996                    $this->flashMessenger()->addMessage('verification_done', 'info');
1997                    return $this->redirect()->toRoute('myresearch-profile');
1998                }
1999            }
2000        }
2001        $this->flashMessenger()->addMessage('recovery_invalid_hash', 'error');
2002        return $this->redirect()->toRoute('myresearch-profile');
2003    }
2004
2005    /**
2006     * Reset the new password form and return the modified view. When a user has
2007     * already been loaded from an existing hash, this resets the hash and updates
2008     * the form so that the user can try again.
2009     *
2010     * @param ?UserEntityInterface $userFromHash User loaded from database, or null if none.
2011     * @param ViewModel            $view         View object
2012     *
2013     * @return ViewModel
2014     */
2015    protected function resetNewPasswordForm(?UserEntityInterface $userFromHash, ViewModel $view)
2016    {
2017        if ($userFromHash) {
2018            $this->getAuthManager()->updateUserVerifyHash($userFromHash);
2019            $view->username = $userFromHash->getUsername();
2020            $view->hash = $userFromHash->getVerifyHash();
2021        }
2022        return $view;
2023    }
2024
2025    /**
2026     * Handling submission of a new password for a user.
2027     *
2028     * @return mixed
2029     */
2030    public function newPasswordAction()
2031    {
2032        // Have we submitted the form?
2033        if (!$this->formWasSubmitted()) {
2034            return $this->redirect()->toRoute('home');
2035        }
2036        // Set up authentication so that we can retrieve the correct password policy:
2037        $this->setUpAuthenticationFromRequest();
2038        // Pull in from POST
2039        $request = $this->getRequest();
2040        $post = $request->getPost();
2041        // Verify hash
2042        $userFromHash = isset($post->hash)
2043            ? $this->getDbService(UserServiceInterface::class)->getUserByVerifyHash($post->hash)
2044            : null;
2045        // View, password policy and Captcha
2046        $view = $this->createViewModel($post);
2047        $view->passwordPolicy = $this->getAuthManager()->getPasswordPolicy();
2048        $view->useCaptcha = $this->captcha()->active('changePassword');
2049        // Check Captcha
2050        if (!$this->formWasSubmitted(useCaptcha: $view->useCaptcha)) {
2051            return $this->resetNewPasswordForm($userFromHash, $view);
2052        }
2053        // Missing or invalid hash
2054        if (!$userFromHash) {
2055            $this->flashMessenger()->addMessage('recovery_user_not_found', 'error');
2056            // Force login or restore hash
2057            $post->username = false;
2058            return $this->forwardTo('MyResearch', 'Recover');
2059        } elseif ($userFromHash->getUsername() !== $post->username) {
2060            $this->flashMessenger()
2061                ->addMessage('authentication_error_invalid', 'error');
2062            return $this->resetNewPasswordForm($userFromHash, $view);
2063        }
2064        // Verify old password if we're logged in
2065        if ($this->getUser()) {
2066            if (isset($post->oldpwd)) {
2067                // Reassign oldpwd to password in the request so login works
2068                $tempPassword = $post->password;
2069                $post->password = $post->oldpwd;
2070                $valid = $this->getAuthManager()->validateCredentials($request);
2071                $post->password = $tempPassword;
2072            } else {
2073                $valid = false;
2074            }
2075            if (!$valid) {
2076                $this->flashMessenger()
2077                    ->addMessage('authentication_error_invalid', 'error');
2078                $view->verifyold = true;
2079                return $view;
2080            }
2081        }
2082        // Update password
2083        try {
2084            $user = $this->getAuthManager()->updatePassword($this->getRequest());
2085        } catch (AuthException $e) {
2086            $this->flashMessenger()->addMessage($e->getMessage(), 'error');
2087            return $view;
2088        }
2089        // Update hash to prevent reusing hash
2090        $this->getAuthManager()->updateUserVerifyHash($user);
2091        // Login
2092        $this->getAuthManager()->login($this->request);
2093        // Return to account home
2094        $this->flashMessenger()->addMessage('new_password_success', 'success');
2095        return $this->redirect()->toRoute('myresearch-home');
2096    }
2097
2098    /**
2099     * Handling submission of a new email for a user.
2100     *
2101     * @return mixed
2102     */
2103    public function changeEmailAction()
2104    {
2105        // Always check that we are logged in and function is enabled first:
2106        if (!($user = $this->getUser())) {
2107            return $this->forceLogin();
2108        }
2109        if (!$this->getAuthManager()->supportsEmailChange()) {
2110            $this->flashMessenger()->addMessage('change_email_disabled', 'error');
2111            return $this->redirect()->toRoute('home');
2112        }
2113        $view = $this->createViewModel($this->params()->fromPost());
2114        // Display email
2115        $view->email = $user->getEmail();
2116        // Identification
2117        $view->useCaptcha = $this->captcha()->active('changeEmail');
2118        // Special case: form was submitted:
2119        if ($this->formWasSubmitted(useCaptcha: $view->useCaptcha)) {
2120            // Do CSRF check
2121            $csrf = $this->serviceLocator->get(CsrfInterface::class);
2122            if (!$csrf->isValid($this->getRequest()->getPost()->get('csrf'))) {
2123                throw new \VuFind\Exception\BadRequest(
2124                    'error_inconsistent_parameters'
2125                );
2126            }
2127            // Update email
2128            $validator = new \Laminas\Validator\EmailAddress();
2129            $email = $this->params()->fromPost('email', '');
2130            try {
2131                if (!$validator->isValid($email)) {
2132                    throw new AuthException('Email address is invalid');
2133                }
2134                $this->getAuthManager()->updateEmail($user, $email);
2135                // If we have a pending change, we need to send a verification email:
2136                if ($user->getPendingEmail()) {
2137                    $this->sendVerificationEmail($user, true);
2138                } else {
2139                    $this->flashMessenger()
2140                        ->addMessage('new_email_success', 'success');
2141                }
2142            } catch (AuthException $e) {
2143                $this->flashMessenger()->addMessage($e->getMessage(), 'error');
2144                return $view;
2145            }
2146            // Return to account home
2147            return $this->redirect()->toRoute('myresearch-home');
2148        } elseif ($this->getConfig()->Authentication->verify_email ?? false) {
2149            $this->flashMessenger()
2150                ->addMessage('change_email_verification_reminder', 'info');
2151        }
2152        $this->addPendingEmailChangeMessage($user);
2153        return $view;
2154    }
2155
2156    /**
2157     * Handling submission of a new password for a user.
2158     *
2159     * @return mixed
2160     */
2161    public function changePasswordAction()
2162    {
2163        if (!$this->getAuthManager()->getIdentity()) {
2164            return $this->forceLogin();
2165        }
2166        // If not submitted, are we logged in?
2167        if (!$this->getAuthManager()->supportsPasswordChange()) {
2168            $this->flashMessenger()->addMessage('recovery_new_disabled', 'error');
2169            return $this->redirect()->toRoute('home');
2170        }
2171        $view = $this->createViewModel($this->params()->fromPost());
2172        // Verify user password
2173        $view->verifyold = true;
2174        // Display username
2175        $user = $this->getUser();
2176        $view->username = $user->getUsername();
2177        // Password policy
2178        $view->passwordPolicy = $this->getAuthManager()
2179            ->getPasswordPolicy();
2180        // Identification
2181        $this->getAuthManager()->updateUserVerifyHash($user);
2182        $view->hash = $user->getVerifyHash();
2183        $view->setTemplate('myresearch/newpassword');
2184        $view->useCaptcha = $this->captcha()->active('changePassword');
2185        return $view;
2186    }
2187
2188    /**
2189     * Delete a login token
2190     *
2191     * @return mixed
2192     */
2193    public function deleteLoginTokenAction()
2194    {
2195        if (!$this->getAuthManager()->getIdentity()) {
2196            return $this->forceLogin();
2197        }
2198        $csrf = $this->serviceLocator->get(CsrfInterface::class);
2199        if (!$csrf->isValid($this->getRequest()->getPost()->get('csrf'))) {
2200            throw new \VuFind\Exception\BadRequest(
2201                'error_inconsistent_parameters'
2202            );
2203        }
2204        $series = $this->params()->fromPost('series', '');
2205        $this->getAuthManager()->deleteToken($series);
2206        return $this->redirect()->toRoute('myresearch-profile');
2207    }
2208
2209    /**
2210     * Delete all login tokens for a user
2211     *
2212     * @return mixed
2213     */
2214    public function deleteUserLoginTokensAction()
2215    {
2216        if (!$this->getAuthManager()->getIdentity()) {
2217            return $this->forceLogin();
2218        }
2219        $csrf = $this->serviceLocator->get(CsrfInterface::class);
2220        if (!$csrf->isValid($this->getRequest()->getPost()->get('csrf'))) {
2221            throw new \VuFind\Exception\BadRequest(
2222                'error_inconsistent_parameters'
2223            );
2224        }
2225        $this->getAuthManager()->deleteUserLoginTokens($this->getUser()->id);
2226        return $this->redirect()->toRoute('myresearch-profile');
2227    }
2228
2229    /**
2230     * Helper function for verification hashes
2231     *
2232     * @param string $hash User-unique hash string from request
2233     *
2234     * @return int age in seconds
2235     */
2236    protected function getHashAge($hash)
2237    {
2238        return intval(substr($hash, -10));
2239    }
2240
2241    /**
2242     * Configure the authentication manager to use a user-specified method.
2243     *
2244     * @return void
2245     */
2246    protected function setUpAuthenticationFromRequest()
2247    {
2248        $method = trim(
2249            $this->params()->fromQuery(
2250                'auth_method',
2251                $this->params()->fromPost('auth_method')
2252            )
2253        );
2254        if (!empty($method)) {
2255            $this->getAuthManager()->setAuthMethod($method);
2256        }
2257    }
2258
2259    /**
2260     * Account deletion
2261     *
2262     * @return mixed
2263     */
2264    public function deleteAccountAction()
2265    {
2266        // Force login:
2267        if (!($user = $this->getUser())) {
2268            return $this->forceLogin();
2269        }
2270
2271        $config = $this->getConfig();
2272        if (empty($config->Authentication->account_deletion)) {
2273            throw new \VuFind\Exception\BadRequest();
2274        }
2275
2276        $view = $this->createViewModel(['accountDeleted' => false]);
2277        if ($this->formWasSubmitted()) {
2278            $csrf = $this->serviceLocator->get(CsrfInterface::class);
2279            if (!$csrf->isValid($this->getRequest()->getPost()->get('csrf'))) {
2280                throw new \VuFind\Exception\BadRequest(
2281                    'error_inconsistent_parameters'
2282                );
2283            } else {
2284                // After successful token verification, clear list to shrink session:
2285                $csrf->trimTokenList(0);
2286            }
2287            $this->serviceLocator->get(UserAccountService::class)->purgeUserData(
2288                $user,
2289                $config->Authentication->delete_comments_with_user ?? true,
2290                $config->Authentication->delete_ratings_with_user ?? true
2291            );
2292            $view->accountDeleted = true;
2293            $view->redirectUrl = $this->getAuthManager()->logout(
2294                $this->getServerUrl('home')
2295            );
2296        } elseif ($this->formWasSubmitted('reset')) {
2297            return $this->redirect()->toRoute('myresearch-profile');
2298        }
2299        return $view;
2300    }
2301
2302    /**
2303     * Unsubscribe a scheduled alert for a saved search.
2304     *
2305     * @return mixed
2306     */
2307    public function unsubscribeAction()
2308    {
2309        $id = $this->params()->fromQuery('id', false);
2310        $key = $this->params()->fromQuery('key', false);
2311        $type = $this->params()->fromQuery('type', 'alert');
2312        if ($id === false || $key === false) {
2313            throw new \Exception('Missing parameters.');
2314        }
2315        $view = $this->createViewModel();
2316        if ($this->params()->fromQuery('confirm', false) == 1) {
2317            if ($type == 'alert') {
2318                $searchService = $this->getDbService(SearchServiceInterface::class);
2319                $search = $searchService->getSearchById($id);
2320                if (!$search) {
2321                    throw new \Exception('Invalid parameters.');
2322                }
2323                $secret = $this->serviceLocator->get(SecretCalculator::class)->getSearchUnsubscribeSecret($search);
2324                if ($key !== $secret) {
2325                    throw new \Exception('Invalid parameters.');
2326                }
2327                $search->setNotificationFrequency(0);
2328                $searchService->persistEntity($search);
2329                $view->success = true;
2330            }
2331        } else {
2332            $view->unsubscribeUrl = $this->getRequest()->getRequestUri() . '&confirm=1';
2333        }
2334        return $view;
2335    }
2336
2337    /**
2338     * Get the ILS pagination helper
2339     *
2340     * @return PaginationHelper
2341     */
2342    protected function getPaginationHelper()
2343    {
2344        if (null === $this->paginationHelper) {
2345            $this->paginationHelper = new PaginationHelper();
2346        }
2347        return $this->paginationHelper;
2348    }
2349
2350    /**
2351     * Are list tags enabled?
2352     *
2353     * @return bool
2354     */
2355    protected function listTagsEnabled()
2356    {
2357        $check = $this->serviceLocator
2358            ->get(\VuFind\Config\AccountCapabilities::class);
2359        return $check->getListTagSetting() === 'enabled';
2360    }
2361
2362    /**
2363     * Add a message about any pending email change to the flash messenger
2364     *
2365     * @param UserEntityInterface $user User
2366     *
2367     * @return void
2368     */
2369    protected function addPendingEmailChangeMessage(UserEntityInterface $user)
2370    {
2371        if ($pending = $user->getPendingEmail()) {
2372            $url = $this->url()->fromRoute(
2373                'myresearch-emailnotverified',
2374                [],
2375                ['query' => ['reverify' => 'true']]
2376            );
2377            $pendingEmailEsc = htmlspecialchars($pending, ENT_COMPAT, 'UTF-8');
2378            $this->flashMessenger()->addInfoMessage(
2379                [
2380                    'html' => true,
2381                    'msg' => 'email_change_pending_html',
2382                    'tokens' => [
2383                        '%%pending%%' => $pendingEmailEsc,
2384                        '%%url%%' => $url,
2385                    ],
2386                ]
2387            );
2388        }
2389    }
2390}