Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 1088
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 / 1088
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 / 12
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 / 25
0.00% covered (danger)
0.00%
0 / 1
20
 verifyEmailAction
0.00% covered (danger)
0.00%
0 / 21
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            $table = $this->getTable('User');
1300            // Case 1: new user:
1301            $user = $table
1302                ->getByUsername($this->getUserVerificationContainer()->user, false);
1303            // Case 2: pending email change:
1304            if (!$user) {
1305                $user = $this->getUser();
1306                if ($user && $user->getPendingEmail()) {
1307                    $change = true;
1308                }
1309            }
1310            $this->sendVerificationEmail($user, $change);
1311        } else {
1312            $this->flashMessenger()->addMessage('verification_email_sent', 'info');
1313        }
1314        return $this->createViewModel();
1315    }
1316
1317    /**
1318     * Creates a confirmation box to delete or not delete the current list
1319     *
1320     * @return mixed
1321     */
1322    public function deletelistAction()
1323    {
1324        // Fail if lists are disabled:
1325        if (!$this->listsEnabled()) {
1326            throw new ForbiddenException('Lists disabled');
1327        }
1328
1329        // Get requested list ID:
1330        $listID = $this->params()
1331            ->fromPost('listID', $this->params()->fromQuery('listID'));
1332
1333        // Have we confirmed this?
1334        $confirm = $this->params()->fromPost(
1335            'confirm',
1336            $this->params()->fromQuery('confirm')
1337        );
1338        if ($confirm) {
1339            try {
1340                $list = $this->getDbService(UserListServiceInterface::class)->getUserListById($listID);
1341                $this->serviceLocator->get(FavoritesService::class)->destroyList($list, $this->getUser());
1342
1343                // Success Message
1344                $this->flashMessenger()->addMessage('fav_list_delete', 'success');
1345            } catch (LoginRequiredException | ListPermissionException $e) {
1346                if (!$this->getUser()) {
1347                    return $this->forceLogin();
1348                }
1349                // Logged in? Then we have to rethrow the exception!
1350                throw $e;
1351            }
1352            // Redirect to MyResearch home
1353            return $this->redirect()->toRoute('myresearch-favorites');
1354        }
1355
1356        // If we got this far, we must display a confirmation message:
1357        return $this->confirm(
1358            'confirm_delete_list_brief',
1359            $this->url()->fromRoute('myresearch-deletelist'),
1360            $this->url()->fromRoute('userList', ['id' => $listID]),
1361            'confirm_delete_list_text',
1362            ['listID' => $listID]
1363        );
1364    }
1365
1366    /**
1367     * Send list of holds to view
1368     *
1369     * @return mixed
1370     *
1371     * @deprecated
1372     */
1373    public function holdsAction()
1374    {
1375        return $this->redirect()->toRoute('holds-list');
1376    }
1377
1378    /**
1379     * Send list of storage retrieval requests to view
1380     *
1381     * @return mixed
1382     */
1383    public function storageRetrievalRequestsAction()
1384    {
1385        // Stop now if the user does not have valid catalog credentials available:
1386        if (!is_array($patron = $this->catalogLogin())) {
1387            return $patron;
1388        }
1389
1390        // Connect to the ILS:
1391        $catalog = $this->getILS();
1392
1393        // Process cancel requests if necessary:
1394        $cancelSRR = $catalog->checkFunction(
1395            'cancelStorageRetrievalRequests',
1396            compact('patron')
1397        );
1398        $view = $this->createViewModel();
1399        $view->cancelResults = $cancelSRR
1400            ? $this->storageRetrievalRequests()->cancelStorageRetrievalRequests(
1401                $catalog,
1402                $patron
1403            )
1404            : [];
1405        // If we need to confirm
1406        if (!is_array($view->cancelResults)) {
1407            return $view->cancelResults;
1408        }
1409
1410        // By default, assume we will not need to display a cancel form:
1411        $view->cancelForm = false;
1412
1413        // Get request details:
1414        $result = $catalog->getMyStorageRetrievalRequests($patron);
1415        $driversNeeded = [];
1416        $this->storageRetrievalRequests()->resetValidation();
1417        foreach ($result as $current) {
1418            // Add cancel details if appropriate:
1419            $current = $this->storageRetrievalRequests()->addCancelDetails(
1420                $catalog,
1421                $current,
1422                $cancelSRR,
1423                $patron
1424            );
1425            if (
1426                $cancelSRR
1427                && $cancelSRR['function'] != 'getCancelStorageRetrievalRequestLink'
1428                && isset($current['cancel_details'])
1429            ) {
1430                // Enable cancel form if necessary:
1431                $view->cancelForm = true;
1432            }
1433
1434            $driversNeeded[] = $current;
1435        }
1436
1437        // Get List of PickUp Libraries based on patron's home library
1438        try {
1439            $view->pickup = $catalog->getPickUpLocations($patron);
1440        } catch (\Exception $e) {
1441            // Do nothing; if we're unable to load information about pickup
1442            // locations, they are not supported and we should ignore them.
1443        }
1444
1445        $view->recordList = $this->ilsRecords()->getDrivers($driversNeeded);
1446        $view->accountStatus = $this->ilsRecords()
1447            ->collectRequestStats($view->recordList);
1448        return $view;
1449    }
1450
1451    /**
1452     * Send list of ill requests to view
1453     *
1454     * @return mixed
1455     */
1456    public function illRequestsAction()
1457    {
1458        // Stop now if the user does not have valid catalog credentials available:
1459        if (!is_array($patron = $this->catalogLogin())) {
1460            return $patron;
1461        }
1462
1463        // Connect to the ILS:
1464        $catalog = $this->getILS();
1465
1466        // Process cancel requests if necessary:
1467        $cancelStatus = $catalog->checkFunction(
1468            'cancelILLRequests',
1469            compact('patron')
1470        );
1471        $view = $this->createViewModel();
1472        $view->cancelResults = $cancelStatus
1473            ? $this->ILLRequests()->cancelILLRequests(
1474                $catalog,
1475                $patron
1476            )
1477            : [];
1478        // If we need to confirm
1479        if (!is_array($view->cancelResults)) {
1480            return $view->cancelResults;
1481        }
1482
1483        // By default, assume we will not need to display a cancel form:
1484        $view->cancelForm = false;
1485
1486        // Get request details:
1487        $result = $catalog->getMyILLRequests($patron);
1488        $driversNeeded = [];
1489        $this->ILLRequests()->resetValidation();
1490        foreach ($result as $current) {
1491            // Add cancel details if appropriate:
1492            $current = $this->ILLRequests()->addCancelDetails(
1493                $catalog,
1494                $current,
1495                $cancelStatus,
1496                $patron
1497            );
1498            if (
1499                $cancelStatus
1500                && $cancelStatus['function'] != 'getCancelILLRequestLink'
1501                && isset($current['cancel_details'])
1502            ) {
1503                // Enable cancel form if necessary:
1504                $view->cancelForm = true;
1505            }
1506
1507            $driversNeeded[] = $current;
1508        }
1509
1510        $view->recordList = $this->ilsRecords()->getDrivers($driversNeeded);
1511        $view->accountStatus = $this->ilsRecords()
1512            ->collectRequestStats($view->recordList);
1513        return $view;
1514    }
1515
1516    /**
1517     * Send list of checked out books to view
1518     *
1519     * @return mixed
1520     */
1521    public function checkedoutAction()
1522    {
1523        // Stop now if the user does not have valid catalog credentials available:
1524        if (!is_array($patron = $this->catalogLogin())) {
1525            return $patron;
1526        }
1527
1528        // Connect to the ILS:
1529        $catalog = $this->getILS();
1530
1531        // Display account blocks, if any:
1532        $this->addAccountBlocksToFlashMessenger($catalog, $patron);
1533
1534        // Get the current renewal status and process renewal form, if necessary:
1535        $renewStatus = $catalog->checkFunction('Renewals', compact('patron'));
1536        $renewResult = $renewStatus
1537            ? $this->renewals()->processRenewals(
1538                $this->getRequest()->getPost(),
1539                $catalog,
1540                $patron,
1541                $this->serviceLocator->get(CsrfInterface::class)
1542            )
1543            : [];
1544
1545        // By default, assume we will not need to display a renewal form:
1546        $renewForm = false;
1547
1548        // Get paging setup:
1549        $config = $this->getConfig();
1550        $pageSize = $config->Catalog->checked_out_page_size ?? 50;
1551        $pageOptions = $this->getPaginationHelper()->getOptions(
1552            (int)$this->params()->fromQuery('page', 1),
1553            $this->params()->fromQuery('sort'),
1554            $pageSize,
1555            $catalog->checkFunction('getMyTransactions', $patron)
1556        );
1557
1558        // Get checked out item details:
1559        $result = $catalog->getMyTransactions($patron, $pageOptions['ilsParams']);
1560
1561        // Build paginator if needed:
1562        $paginator = $this->getPaginationHelper()->getPaginator(
1563            $pageOptions,
1564            $result['count'],
1565            $result['records']
1566        );
1567        if ($paginator) {
1568            $pageStart = $paginator->getAbsoluteItemNumber(1) - 1;
1569            $pageEnd = $paginator->getAbsoluteItemNumber($pageOptions['limit']) - 1;
1570        } else {
1571            $pageStart = 0;
1572            $pageEnd = $result['count'];
1573        }
1574
1575        // If the results are not paged in the ILS, collect up to date stats for ajax
1576        // account notifications:
1577        if (
1578            !empty($config->Authentication->enableAjax)
1579            && (!$pageOptions['ilsPaging'] || !$paginator
1580            || $result['count'] <= $pageSize)
1581        ) {
1582            $accountStatus = $this->getTransactionSummary($result['records']);
1583        } else {
1584            $accountStatus = null;
1585        }
1586
1587        $driversNeeded = $hiddenTransactions = [];
1588        foreach ($result['records'] as $i => $current) {
1589            // Add renewal details if appropriate:
1590            $current = $this->renewals()->addRenewDetails(
1591                $catalog,
1592                $current,
1593                $renewStatus
1594            );
1595            if (
1596                $renewStatus && !isset($current['renew_link'])
1597                && $current['renewable']
1598            ) {
1599                // Enable renewal form if necessary:
1600                $renewForm = true;
1601            }
1602
1603            // Build record drivers (only for the current visible page):
1604            if ($pageOptions['ilsPaging'] || ($i >= $pageStart && $i <= $pageEnd)) {
1605                $driversNeeded[] = $current;
1606            } else {
1607                $hiddenTransactions[] = $current;
1608            }
1609        }
1610
1611        $transactions = $this->ilsRecords()->getDrivers($driversNeeded);
1612
1613        $displayItemBarcode
1614            = !empty($config->Catalog->display_checked_out_item_barcode);
1615
1616        $ilsPaging = $pageOptions['ilsPaging'];
1617        $sortList = $pageOptions['sortList'];
1618        $params = $pageOptions['ilsParams'];
1619        return $this->createViewModel(
1620            compact(
1621                'transactions',
1622                'renewForm',
1623                'renewResult',
1624                'paginator',
1625                'ilsPaging',
1626                'hiddenTransactions',
1627                'displayItemBarcode',
1628                'sortList',
1629                'params',
1630                'accountStatus'
1631            )
1632        );
1633    }
1634
1635    /**
1636     * Send list of historic loans to view
1637     *
1638     * @return mixed
1639     */
1640    public function historicloansAction()
1641    {
1642        return $this->redirect()->toRoute('checkouts-history');
1643    }
1644
1645    /**
1646     * Send list of fines to view
1647     *
1648     * @return mixed
1649     */
1650    public function finesAction()
1651    {
1652        // Stop now if the user does not have valid catalog credentials available:
1653        if (!is_array($patron = $this->catalogLogin())) {
1654            return $patron;
1655        }
1656
1657        // Connect to the ILS:
1658        $catalog = $this->getILS();
1659
1660        // Get fine details:
1661        $result = $catalog->getMyFines($patron);
1662        $fines = [];
1663        $driversNeeded = [];
1664        foreach ($result as $i => $row) {
1665            // If we have an id, add it to list of record drivers to load:
1666            if ($row['id'] ?? false) {
1667                $driversNeeded[$i] = [
1668                    'id' => $row['id'],
1669                    'source' => $row['source'] ?? DEFAULT_SEARCH_BACKEND,
1670                ];
1671            }
1672            // Store by original index so that we can access it when loading record
1673            // drivers:
1674            $fines[$i] = $row;
1675        }
1676
1677        if ($driversNeeded) {
1678            $recordLoader = $this->serviceLocator->get(\VuFind\Record\Loader::class);
1679            $drivers = $recordLoader->loadBatch($driversNeeded, true);
1680            foreach ($drivers as $i => $driver) {
1681                $fines[$i]['driver'] = $driver;
1682                if (empty($fines[$i]['title'])) {
1683                    $fines[$i]['title'] = $driver->getShortTitle();
1684                }
1685            }
1686        }
1687
1688        // Clean up array keys:
1689        $fines = array_values($fines);
1690
1691        // Collect up to date stats for ajax account notifications:
1692        if (!empty($this->getConfig()->Authentication->enableAjax)) {
1693            $accountStatus = $this->getFineSummary(
1694                $fines,
1695                $this->serviceLocator->get(\VuFind\Service\CurrencyFormatter::class)
1696            );
1697        } else {
1698            $accountStatus = null;
1699        }
1700
1701        return $this->createViewModel(compact('fines', 'accountStatus'));
1702    }
1703
1704    /**
1705     * Convenience method to get a session initiator URL. Returns false if not
1706     * applicable.
1707     *
1708     * @return string|bool
1709     */
1710    protected function getSessionInitiator()
1711    {
1712        $url = $this->getServerUrl('myresearch-home');
1713        return $this->getAuthManager()->getSessionInitiator($url);
1714    }
1715
1716    /**
1717     * Send account recovery email
1718     *
1719     * @return mixed
1720     */
1721    public function recoverAction()
1722    {
1723        // Make sure we're configured to do this
1724        $this->setUpAuthenticationFromRequest();
1725        if (!$this->getAuthManager()->supportsRecovery()) {
1726            $this->flashMessenger()->addMessage('recovery_disabled', 'error');
1727            return $this->redirect()->toRoute('myresearch-home');
1728        }
1729        if ($this->getUser()) {
1730            return $this->redirect()->toRoute('myresearch-home');
1731        }
1732        // Database
1733        $table = $this->getTable('User');
1734        $user = false;
1735        // Check if we have a submitted form, and use the information
1736        // to get the user's information
1737        if ($email = $this->params()->fromPost('email')) {
1738            $user = $table->getByEmail($email);
1739        } elseif ($username = $this->params()->fromPost('username')) {
1740            $user = $table->getByUsername($username, false);
1741        }
1742        $view = $this->createViewModel();
1743        $view->useCaptcha = $this->captcha()->active('passwordRecovery');
1744        // If we have a submitted form
1745        if ($this->formWasSubmitted(useCaptcha: $view->useCaptcha)) {
1746            if ($user) {
1747                $this->sendRecoveryEmail($user, $this->getConfig());
1748            } else {
1749                $this->flashMessenger()
1750                    ->addMessage('recovery_user_not_found', 'error');
1751            }
1752        }
1753        return $view;
1754    }
1755
1756    /**
1757     * Helper function for recoverAction
1758     *
1759     * @param UserEntityInterface $user   User object we're recovering
1760     * @param \VuFind\Config      $config Configuration object
1761     *
1762     * @return void (sends email or adds error message)
1763     */
1764    protected function sendRecoveryEmail(UserEntityInterface $user, $config)
1765    {
1766        // If we can't find a user
1767        if (!$user) {
1768            $this->flashMessenger()->addMessage('recovery_user_not_found', 'error');
1769        } else {
1770            // Make sure we've waited long enough
1771            $hashtime = $this->getHashAge($user->getVerifyHash());
1772            $recoveryInterval = $config->Authentication->recover_interval ?? 60;
1773            if (time() - $hashtime < $recoveryInterval) {
1774                $this->flashMessenger()->addMessage('recovery_too_soon', 'error');
1775            } else {
1776                // Attempt to send the email
1777                try {
1778                    // Create a fresh hash
1779                    $this->getAuthManager()->updateUserVerifyHash($user);
1780                    $config = $this->getConfig();
1781                    $renderer = $this->getViewRenderer();
1782                    $method = $this->getAuthManager()->getAuthMethod();
1783                    // Custom template for emails (text-only)
1784                    $message = $renderer->render(
1785                        'Email/recover-password.phtml',
1786                        [
1787                            'library' => $config->Site->title,
1788                            'url' => $this->getServerUrl('myresearch-verify')
1789                                . '?hash='
1790                                . $user->getVerifyHash() . '&auth_method=' . $method,
1791                        ]
1792                    );
1793                    $this->serviceLocator->get(Mailer::class)->send(
1794                        $user->getEmail(),
1795                        $config->Site->email,
1796                        $this->translate('recovery_email_subject'),
1797                        $message
1798                    );
1799                    $this->flashMessenger()
1800                        ->addMessage('recovery_email_sent', 'success');
1801                } catch (MailException $e) {
1802                    $this->flashMessenger()->addMessage($e->getDisplayMessage(), 'error');
1803                }
1804            }
1805        }
1806    }
1807
1808    /**
1809     * Send a verify email message for the first time (only if the user does not
1810     * already have a hash).
1811     *
1812     * @param UserEntityInterface $user User object we're recovering
1813     *
1814     * @return void (sends email or adds error message)
1815     */
1816    protected function sendFirstVerificationEmail(UserEntityInterface $user)
1817    {
1818        if (!$user->getVerifyHash()) {
1819            $this->sendVerificationEmail($user);
1820        }
1821    }
1822
1823    /**
1824     * When a request to change a user's email address has been received, we should
1825     * send a notification to the old email address for the user's information.
1826     *
1827     * @param UserEntityInterface $user     User whose email address is being changed
1828     * @param string              $newEmail New email address
1829     *
1830     * @return void (sends email or adds error message)
1831     */
1832    protected function sendChangeNotificationEmail($user, $newEmail)
1833    {
1834        // Don't send the notification if the existing email is not valid:
1835        $validator = new \Laminas\Validator\EmailAddress();
1836        if (!$validator->isValid($user->getEmail())) {
1837            return;
1838        }
1839
1840        $config = $this->getConfig();
1841        $renderer = $this->getViewRenderer();
1842        // Custom template for emails (text-only)
1843        $message = $renderer->render(
1844            'Email/notify-email-change.phtml',
1845            [
1846                'library' => $config->Site->title,
1847                'url' => $this->getServerUrl('home'),
1848                'email' => $config->Site->email,
1849                'newEmail' => $newEmail,
1850            ]
1851        );
1852        // If the user is setting up a new account, use the main email
1853        // address; if they have a pending address change, use that.
1854        $this->serviceLocator->get(Mailer::class)->send(
1855            $user->getEmail(),
1856            $config->Site->email,
1857            $this->translate('change_notification_email_subject'),
1858            $message
1859        );
1860    }
1861
1862    /**
1863     * Send a verify email message.
1864     *
1865     * @param ?UserEntityInterface $user   User object we're recovering
1866     * @param bool                 $change Is the user changing their email (true)
1867     * or setting up a new account (false).
1868     *
1869     * @return void (sends email or adds error message)
1870     */
1871    protected function sendVerificationEmail($user, $change = false)
1872    {
1873        // If we can't find a user
1874        if (null == $user) {
1875            $this->flashMessenger()
1876                ->addMessage('verification_user_not_found', 'error');
1877        } else {
1878            // Make sure we've waited long enough
1879            $hashtime = $this->getHashAge($user->getVerifyHash());
1880            $recoveryInterval = $this->getConfig()->Authentication->recover_interval
1881                ?? 60;
1882            if (time() - $hashtime < $recoveryInterval && !$change) {
1883                $this->flashMessenger()
1884                    ->addMessage('verification_too_soon', 'error');
1885            } else {
1886                // Attempt to send the email
1887                try {
1888                    // Create a fresh hash
1889                    $this->getAuthManager()->updateUserVerifyHash($user);
1890                    $config = $this->getConfig();
1891                    $renderer = $this->getViewRenderer();
1892                    // Custom template for emails (text-only)
1893                    $message = $renderer->render(
1894                        'Email/verify-email.phtml',
1895                        [
1896                            'library' => $config->Site->title,
1897                            'url' => $this->getServerUrl('myresearch-verifyemail')
1898                                . '?hash=' . urlencode($user->getVerifyHash()),
1899                        ]
1900                    );
1901                    // If the user is setting up a new account, use the main email
1902                    // address; if they have a pending address change, use that.
1903                    $to = ($pending = $user->getPendingEmail()) ? $pending : $user->getEmail();
1904                    $this->serviceLocator->get(Mailer::class)->send(
1905                        $to,
1906                        $config->Site->email,
1907                        $this->translate('verification_email_subject'),
1908                        $message
1909                    );
1910                    $flashMessage = $change
1911                        ? 'verification_email_change_sent'
1912                        : 'verification_email_sent';
1913                    $this->flashMessenger()->addMessage($flashMessage, 'info');
1914                    // If this is an email change, send a notification to the old
1915                    // email address as well.
1916                    if ($change) {
1917                        $this->sendChangeNotificationEmail($user, $to);
1918                    }
1919                } catch (MailException $e) {
1920                    $this->flashMessenger()->addMessage($e->getDisplayMessage(), 'error');
1921                }
1922            }
1923        }
1924    }
1925
1926    /**
1927     * Receive a hash and display the new password form if it's valid
1928     *
1929     * @return mixed
1930     */
1931    public function verifyAction()
1932    {
1933        // If we have a submitted form
1934        if ($hash = $this->params()->fromQuery('hash')) {
1935            $hashtime = $this->getHashAge($hash);
1936            $config = $this->getConfig();
1937            // Check if hash is expired
1938            $hashLifetime = $config->Authentication->recover_hash_lifetime
1939                ?? 1209600; // Two weeks
1940            if (time() - $hashtime > $hashLifetime) {
1941                $this->flashMessenger()
1942                    ->addMessage('recovery_expired_hash', 'error');
1943                return $this->forwardTo('MyResearch', 'Login');
1944            } else {
1945                $table = $this->getTable('User');
1946                // If the hash is valid, forward user to create new password
1947                // Also treat email address as verified
1948                if ($user = $table->getByVerifyHash($hash)) {
1949                    $user->setEmailVerified(new DateTime());
1950                    $this->getDbService(UserServiceInterface::class)->persistEntity($user);
1951                    $this->setUpAuthenticationFromRequest();
1952                    $view = $this->createViewModel();
1953                    $view->auth_method = $this->getAuthManager()->getAuthMethod();
1954                    $view->hash = $hash;
1955                    $view->username = $user->getUsername();
1956                    $view->useCaptcha = $this->captcha()->active('changePassword');
1957                    $view->passwordPolicy = $this->getAuthManager()
1958                        ->getPasswordPolicy();
1959                    $view->setTemplate('myresearch/newpassword');
1960                    return $view;
1961                }
1962            }
1963        }
1964        $this->flashMessenger()->addMessage('recovery_invalid_hash', 'error');
1965        return $this->forwardTo('MyResearch', 'Login');
1966    }
1967
1968    /**
1969     * Receive a hash and display the new password form if it's valid
1970     *
1971     * @return mixed
1972     */
1973    public function verifyEmailAction()
1974    {
1975        // If we have a submitted form
1976        if ($hash = $this->params()->fromQuery('hash')) {
1977            $hashtime = $this->getHashAge($hash);
1978            $config = $this->getConfig();
1979            // Check if hash is expired
1980            $hashLifetime = $config->Authentication->recover_hash_lifetime
1981                ?? 1209600; // Two weeks
1982            if (time() - $hashtime > $hashLifetime) {
1983                $this->flashMessenger()
1984                    ->addMessage('recovery_expired_hash', 'error');
1985                return $this->forwardTo('MyResearch', 'Profile');
1986            } else {
1987                $table = $this->getTable('User');
1988                // If the hash is valid, store validation in DB and forward to login
1989                if ($user = $table->getByVerifyHash($hash)) {
1990                    // Apply pending email address change, if applicable:
1991                    if ($pending = $user->getPendingEmail()) {
1992                        $this->getDbService(UserServiceInterface::class)
1993                            ->updateUserEmail($user, $pending, true);
1994                        $user->setPendingEmail('');
1995                    }
1996                    $user->setEmailVerified(new DateTime());
1997                    $this->getDbService(UserServiceInterface::class)->persistEntity($user);
1998
1999                    $this->flashMessenger()->addMessage('verification_done', 'info');
2000                    return $this->redirect()->toRoute('myresearch-profile');
2001                }
2002            }
2003        }
2004        $this->flashMessenger()->addMessage('recovery_invalid_hash', 'error');
2005        return $this->redirect()->toRoute('myresearch-profile');
2006    }
2007
2008    /**
2009     * Reset the new password form and return the modified view. When a user has
2010     * already been loaded from an existing hash, this resets the hash and updates
2011     * the form so that the user can try again.
2012     *
2013     * @param mixed     $userFromHash User loaded from database, or false if none.
2014     * @param ViewModel $view         View object
2015     *
2016     * @return ViewModel
2017     */
2018    protected function resetNewPasswordForm($userFromHash, ViewModel $view)
2019    {
2020        if ($userFromHash) {
2021            $this->getAuthManager()->updateUserVerifyHash($userFromHash);
2022            $view->username = $userFromHash->username;
2023            $view->hash = $userFromHash->verify_hash;
2024        }
2025        return $view;
2026    }
2027
2028    /**
2029     * Handling submission of a new password for a user.
2030     *
2031     * @return mixed
2032     */
2033    public function newPasswordAction()
2034    {
2035        // Have we submitted the form?
2036        if (!$this->formWasSubmitted()) {
2037            return $this->redirect()->toRoute('home');
2038        }
2039        // Set up authentication so that we can retrieve the correct password policy:
2040        $this->setUpAuthenticationFromRequest();
2041        // Pull in from POST
2042        $request = $this->getRequest();
2043        $post = $request->getPost();
2044        // Verify hash
2045        $userFromHash = isset($post->hash)
2046            ? $this->getTable('User')->getByVerifyHash($post->hash)
2047            : false;
2048        // View, password policy and Captcha
2049        $view = $this->createViewModel($post);
2050        $view->passwordPolicy = $this->getAuthManager()->getPasswordPolicy();
2051        $view->useCaptcha = $this->captcha()->active('changePassword');
2052        // Check Captcha
2053        if (!$this->formWasSubmitted(useCaptcha: $view->useCaptcha)) {
2054            return $this->resetNewPasswordForm($userFromHash, $view);
2055        }
2056        // Missing or invalid hash
2057        if (false == $userFromHash) {
2058            $this->flashMessenger()->addMessage('recovery_user_not_found', 'error');
2059            // Force login or restore hash
2060            $post->username = false;
2061            return $this->forwardTo('MyResearch', 'Recover');
2062        } elseif ($userFromHash->username !== $post->username) {
2063            $this->flashMessenger()
2064                ->addMessage('authentication_error_invalid', 'error');
2065            return $this->resetNewPasswordForm($userFromHash, $view);
2066        }
2067        // Verify old password if we're logged in
2068        if ($this->getUser()) {
2069            if (isset($post->oldpwd)) {
2070                // Reassign oldpwd to password in the request so login works
2071                $tempPassword = $post->password;
2072                $post->password = $post->oldpwd;
2073                $valid = $this->getAuthManager()->validateCredentials($request);
2074                $post->password = $tempPassword;
2075            } else {
2076                $valid = false;
2077            }
2078            if (!$valid) {
2079                $this->flashMessenger()
2080                    ->addMessage('authentication_error_invalid', 'error');
2081                $view->verifyold = true;
2082                return $view;
2083            }
2084        }
2085        // Update password
2086        try {
2087            $user = $this->getAuthManager()->updatePassword($this->getRequest());
2088        } catch (AuthException $e) {
2089            $this->flashMessenger()->addMessage($e->getMessage(), 'error');
2090            return $view;
2091        }
2092        // Update hash to prevent reusing hash
2093        $this->getAuthManager()->updateUserVerifyHash($user);
2094        // Login
2095        $this->getAuthManager()->login($this->request);
2096        // Return to account home
2097        $this->flashMessenger()->addMessage('new_password_success', 'success');
2098        return $this->redirect()->toRoute('myresearch-home');
2099    }
2100
2101    /**
2102     * Handling submission of a new email for a user.
2103     *
2104     * @return mixed
2105     */
2106    public function changeEmailAction()
2107    {
2108        // Always check that we are logged in and function is enabled first:
2109        if (!($user = $this->getUser())) {
2110            return $this->forceLogin();
2111        }
2112        if (!$this->getAuthManager()->supportsEmailChange()) {
2113            $this->flashMessenger()->addMessage('change_email_disabled', 'error');
2114            return $this->redirect()->toRoute('home');
2115        }
2116        $view = $this->createViewModel($this->params()->fromPost());
2117        // Display email
2118        $view->email = $user->getEmail();
2119        // Identification
2120        $view->useCaptcha = $this->captcha()->active('changeEmail');
2121        // Special case: form was submitted:
2122        if ($this->formWasSubmitted(useCaptcha: $view->useCaptcha)) {
2123            // Do CSRF check
2124            $csrf = $this->serviceLocator->get(CsrfInterface::class);
2125            if (!$csrf->isValid($this->getRequest()->getPost()->get('csrf'))) {
2126                throw new \VuFind\Exception\BadRequest(
2127                    'error_inconsistent_parameters'
2128                );
2129            }
2130            // Update email
2131            $validator = new \Laminas\Validator\EmailAddress();
2132            $email = $this->params()->fromPost('email', '');
2133            try {
2134                if (!$validator->isValid($email)) {
2135                    throw new AuthException('Email address is invalid');
2136                }
2137                $this->getAuthManager()->updateEmail($user, $email);
2138                // If we have a pending change, we need to send a verification email:
2139                if ($user->getPendingEmail()) {
2140                    $this->sendVerificationEmail($user, true);
2141                } else {
2142                    $this->flashMessenger()
2143                        ->addMessage('new_email_success', 'success');
2144                }
2145            } catch (AuthException $e) {
2146                $this->flashMessenger()->addMessage($e->getMessage(), 'error');
2147                return $view;
2148            }
2149            // Return to account home
2150            return $this->redirect()->toRoute('myresearch-home');
2151        } elseif ($this->getConfig()->Authentication->verify_email ?? false) {
2152            $this->flashMessenger()
2153                ->addMessage('change_email_verification_reminder', 'info');
2154        }
2155        $this->addPendingEmailChangeMessage($user);
2156        return $view;
2157    }
2158
2159    /**
2160     * Handling submission of a new password for a user.
2161     *
2162     * @return mixed
2163     */
2164    public function changePasswordAction()
2165    {
2166        if (!$this->getAuthManager()->getIdentity()) {
2167            return $this->forceLogin();
2168        }
2169        // If not submitted, are we logged in?
2170        if (!$this->getAuthManager()->supportsPasswordChange()) {
2171            $this->flashMessenger()->addMessage('recovery_new_disabled', 'error');
2172            return $this->redirect()->toRoute('home');
2173        }
2174        $view = $this->createViewModel($this->params()->fromPost());
2175        // Verify user password
2176        $view->verifyold = true;
2177        // Display username
2178        $user = $this->getUser();
2179        $view->username = $user->getUsername();
2180        // Password policy
2181        $view->passwordPolicy = $this->getAuthManager()
2182            ->getPasswordPolicy();
2183        // Identification
2184        $this->getAuthManager()->updateUserVerifyHash($user);
2185        $view->hash = $user->getVerifyHash();
2186        $view->setTemplate('myresearch/newpassword');
2187        $view->useCaptcha = $this->captcha()->active('changePassword');
2188        return $view;
2189    }
2190
2191    /**
2192     * Delete a login token
2193     *
2194     * @return mixed
2195     */
2196    public function deleteLoginTokenAction()
2197    {
2198        if (!$this->getAuthManager()->getIdentity()) {
2199            return $this->forceLogin();
2200        }
2201        $csrf = $this->serviceLocator->get(CsrfInterface::class);
2202        if (!$csrf->isValid($this->getRequest()->getPost()->get('csrf'))) {
2203            throw new \VuFind\Exception\BadRequest(
2204                'error_inconsistent_parameters'
2205            );
2206        }
2207        $series = $this->params()->fromPost('series', '');
2208        $this->getAuthManager()->deleteToken($series);
2209        return $this->redirect()->toRoute('myresearch-profile');
2210    }
2211
2212    /**
2213     * Delete all login tokens for a user
2214     *
2215     * @return mixed
2216     */
2217    public function deleteUserLoginTokensAction()
2218    {
2219        if (!$this->getAuthManager()->getIdentity()) {
2220            return $this->forceLogin();
2221        }
2222        $csrf = $this->serviceLocator->get(CsrfInterface::class);
2223        if (!$csrf->isValid($this->getRequest()->getPost()->get('csrf'))) {
2224            throw new \VuFind\Exception\BadRequest(
2225                'error_inconsistent_parameters'
2226            );
2227        }
2228        $this->getAuthManager()->deleteUserLoginTokens($this->getUser()->id);
2229        return $this->redirect()->toRoute('myresearch-profile');
2230    }
2231
2232    /**
2233     * Helper function for verification hashes
2234     *
2235     * @param string $hash User-unique hash string from request
2236     *
2237     * @return int age in seconds
2238     */
2239    protected function getHashAge($hash)
2240    {
2241        return intval(substr($hash, -10));
2242    }
2243
2244    /**
2245     * Configure the authentication manager to use a user-specified method.
2246     *
2247     * @return void
2248     */
2249    protected function setUpAuthenticationFromRequest()
2250    {
2251        $method = trim(
2252            $this->params()->fromQuery(
2253                'auth_method',
2254                $this->params()->fromPost('auth_method')
2255            )
2256        );
2257        if (!empty($method)) {
2258            $this->getAuthManager()->setAuthMethod($method);
2259        }
2260    }
2261
2262    /**
2263     * Account deletion
2264     *
2265     * @return mixed
2266     */
2267    public function deleteAccountAction()
2268    {
2269        // Force login:
2270        if (!($user = $this->getUser())) {
2271            return $this->forceLogin();
2272        }
2273
2274        $config = $this->getConfig();
2275        if (empty($config->Authentication->account_deletion)) {
2276            throw new \VuFind\Exception\BadRequest();
2277        }
2278
2279        $view = $this->createViewModel(['accountDeleted' => false]);
2280        if ($this->formWasSubmitted()) {
2281            $csrf = $this->serviceLocator->get(CsrfInterface::class);
2282            if (!$csrf->isValid($this->getRequest()->getPost()->get('csrf'))) {
2283                throw new \VuFind\Exception\BadRequest(
2284                    'error_inconsistent_parameters'
2285                );
2286            } else {
2287                // After successful token verification, clear list to shrink session:
2288                $csrf->trimTokenList(0);
2289            }
2290            $this->serviceLocator->get(UserAccountService::class)->purgeUserData(
2291                $user,
2292                $config->Authentication->delete_comments_with_user ?? true,
2293                $config->Authentication->delete_ratings_with_user ?? true
2294            );
2295            $view->accountDeleted = true;
2296            $view->redirectUrl = $this->getAuthManager()->logout(
2297                $this->getServerUrl('home')
2298            );
2299        } elseif ($this->formWasSubmitted('reset')) {
2300            return $this->redirect()->toRoute('myresearch-profile');
2301        }
2302        return $view;
2303    }
2304
2305    /**
2306     * Unsubscribe a scheduled alert for a saved search.
2307     *
2308     * @return mixed
2309     */
2310    public function unsubscribeAction()
2311    {
2312        $id = $this->params()->fromQuery('id', false);
2313        $key = $this->params()->fromQuery('key', false);
2314        $type = $this->params()->fromQuery('type', 'alert');
2315        if ($id === false || $key === false) {
2316            throw new \Exception('Missing parameters.');
2317        }
2318        $view = $this->createViewModel();
2319        if ($this->params()->fromQuery('confirm', false) == 1) {
2320            if ($type == 'alert') {
2321                $searchService = $this->getDbService(SearchServiceInterface::class);
2322                $search = $searchService->getSearchById($id);
2323                if (!$search) {
2324                    throw new \Exception('Invalid parameters.');
2325                }
2326                $secret = $this->serviceLocator->get(SecretCalculator::class)->getSearchUnsubscribeSecret($search);
2327                if ($key !== $secret) {
2328                    throw new \Exception('Invalid parameters.');
2329                }
2330                $search->setNotificationFrequency(0);
2331                $searchService->persistEntity($search);
2332                $view->success = true;
2333            }
2334        } else {
2335            $view->unsubscribeUrl = $this->getRequest()->getRequestUri() . '&confirm=1';
2336        }
2337        return $view;
2338    }
2339
2340    /**
2341     * Get the ILS pagination helper
2342     *
2343     * @return PaginationHelper
2344     */
2345    protected function getPaginationHelper()
2346    {
2347        if (null === $this->paginationHelper) {
2348            $this->paginationHelper = new PaginationHelper();
2349        }
2350        return $this->paginationHelper;
2351    }
2352
2353    /**
2354     * Are list tags enabled?
2355     *
2356     * @return bool
2357     */
2358    protected function listTagsEnabled()
2359    {
2360        $check = $this->serviceLocator
2361            ->get(\VuFind\Config\AccountCapabilities::class);
2362        return $check->getListTagSetting() === 'enabled';
2363    }
2364
2365    /**
2366     * Add a message about any pending email change to the flash messenger
2367     *
2368     * @param UserEntityInterface $user User
2369     *
2370     * @return void
2371     */
2372    protected function addPendingEmailChangeMessage(UserEntityInterface $user)
2373    {
2374        if ($pending = $user->getPendingEmail()) {
2375            $url = $this->url()->fromRoute(
2376                'myresearch-emailnotverified',
2377                [],
2378                ['query' => ['reverify' => 'true']]
2379            );
2380            $pendingEmailEsc = htmlspecialchars($pending, ENT_COMPAT, 'UTF-8');
2381            $this->flashMessenger()->addInfoMessage(
2382                [
2383                    'html' => true,
2384                    'msg' => 'email_change_pending_html',
2385                    'tokens' => [
2386                        '%%pending%%' => $pendingEmailEsc,
2387                        '%%url%%' => $url,
2388                    ],
2389                ]
2390            );
2391        }
2392    }
2393}