<?php
namespace XF\Pub\Controller;
use XF\Mvc\ParameterBag;
use XF\Mvc\Reply\AbstractReply;
use function boolval, get_class, in_array, is_array, strlen;
abstract class AbstractController extends \XF\Mvc\Controller
{
protected function preDispatchType($action, ParameterBag $params)
{
$this->checkTfaRedirect();
$this->assertCorrectVersion($action);
$this->assertIpNotBanned();
$this->assertNotBanned();
$this->assertNotRejected($action);
$this->assertNotDisabled($action);
$this->assertCanonicalBaseUrl($action);
$this->assertViewingPermissions($action);
$this->assertBoardActive($action);
$this->assertTfaRequirement($action);
$this->assertNotSecurityLocked($action);
$this->assertPolicyAcceptance($action);
if ($this->isDiscouraged())
{
$this->discourage($action);
}
$this->preDispatchController($action, $params);
}
protected function preDispatchController($action, ParameterBag $params)
{
}
protected function postDispatchType($action, ParameterBag $params, AbstractReply &$reply)
{
$this->postDispatchController($action, $params, $reply);
$this->updateSessionActivity($action, $params, $reply);
$isCacheable = ($reply instanceof \XF\Mvc\Reply\View || $reply instanceof \XF\Mvc\Reply\Reroute);
if (!$isCacheable)
{
// don't allow caching of anything other than normal views to prevent accidental caching
// of errors or temporary messages
\XF\Pub\App::$allowPageCache = false;
}
}
protected function postDispatchController($action, ParameterBag $params, AbstractReply &$reply)
{
}
protected function updateSessionActivity($action, ParameterBag $params, AbstractReply &$reply)
{
if ($this->canUpdateSessionActivity($action, $params, $reply, $viewState))
{
$controller = $this->app->extension()->resolveExtendedClassToRoot($this);
// log these details for page caching regardless of whether we want to update for this request
$reply->setViewOption('sessionActivity', [
'controller' => $controller,
'action' => $action,
'params' => $params->params(),
'viewState' => $viewState
]);
if ($this->request->isPrefetch())
{
// never update the session activity for this; the user didn't see it
return;
}
/** @var \XF\Repository\SessionActivity $activityRepo */
$activityRepo = $this->repository('XF:SessionActivity');
$activityRepo->updateSessionActivity(
\XF::visitor()->user_id, $this->request->getIp(),
$controller, $action, $params->params(), $viewState,
$this->request->getRobotName()
);
}
}
protected function canUpdateSessionActivity($action, ParameterBag $params, AbstractReply &$reply, &$viewState)
{
// don't update session activity for an AJAX request
if ($this->request->isXhr())
{
return false;
}
$viewState = 'error';
switch (get_class($reply))
{
case 'XF\Mvc\Reply\Redirect':
case 'XF\Mvc\Reply\Reroute':
return false; // don't update anything, assume the next page will do it
case 'XF\Mvc\Reply\Message':
case 'XF\Mvc\Reply\View':
$viewState = 'valid';
break;
}
if ($reply->getResponseCode() >= 400)
{
$viewState = 'error';
}
return true;
}
public function checkTfaRedirect()
{
$session = $this->session();
if ($session->tfaLoginRedirect)
{
unset($session->tfaLoginRedirect);
if (\XF::visitor()->user_id || !$session->tfaLoginUserId)
{
return;
}
throw $this->exception($this->redirect($this->buildLink('login/two-step', null, [
'_xfRedirect' => $this->request->getFullRequestUri(),
'remember' => 1
])));
}
}
public function assertRegistrationRequired()
{
if (!\XF::visitor()->user_id)
{
throw $this->exception(
$this->plugin('XF:Error')->actionRegistrationRequired()
);
}
}
public function assertIpNotBanned()
{
$bannedIps = $this->app()->container('bannedIps');
$result = \XF\Util\Ip::checkIpsAgainstBinaryRangeList($this->request->getAllIps(), $bannedIps['data']);
if (is_array($result))
{
/** @var \XF\Repository\Banning $repo */
$repo = $this->repository('XF:Banning');
$matched = $repo->findIpMatchesByRange($result[0], $result[1])
->where('match_type', 'banned');
foreach ($matched->fetch() AS $match)
{
$match->fastUpdate('last_triggered_date', time());
}
}
if ($result)
{
throw $this->exception(
$this->plugin('XF:Error')->actionBannedIp()
);
}
}
public function assertNotBanned()
{
if (\XF::visitor()->is_banned)
{
throw $this->exception(
$this->plugin('XF:Error')->actionBanned()
);
}
}
public function assertNotRejected($action)
{
if (\XF::visitor()->user_state == 'rejected')
{
throw $this->exception(
$this->plugin('XF:Error')->actionRejected()
);
}
}
public function assertNotDisabled($action)
{
if (\XF::visitor()->user_state == 'disabled')
{
throw $this->exception(
$this->plugin('XF:Error')->actionDisabled()
);
}
}
public function assertCanonicalBaseUrl($action)
{
if ($this->responseType != 'html')
{
return;
}
if (!$this->request->isGet() && !$this->request->isHead())
{
return;
}
$options = $this->options();
if (!$options->boardUrlCanonical)
{
return;
}
$request = $this->request;
$boardUrl = rtrim($options->boardUrl, '/');
$fullBasePath = rtrim($request->getFullBasePath(), '/');
if ($fullBasePath == $options->boardUrl)
{
// the URL is already canonical
return;
}
$requestUri = $request->getFullRequestUri();
if (strpos($requestUri, $fullBasePath) === 0)
{
$extendedPath = ltrim(substr($requestUri, strlen($fullBasePath)), '/');
$newUrl = $boardUrl . '/' . $extendedPath;
throw $this->exception($this->redirectPermanently($newUrl));
}
}
public function assertViewingPermissions($action)
{
if (!\XF::visitor()->hasPermission('general', 'view'))
{
$reply = $this->noPermission();
$reply->setPageParam('skipSidebarWidgets', true);
throw $this->exception($reply);
}
}
public function assertBoardActive($action)
{
$options = $this->options();
if (!$options->boardActive && !\XF::visitor()->is_admin)
{
$reply = $this->message(new \XF\PreEscaped($options->boardInactiveMessage), $this->app->config('serviceUnavailableCode'));
$reply->setPageParam('skipSidebarWidgets', true);
throw $this->exception($reply);
}
}
public function assertTfaRequirement($action)
{
$visitor = \XF::visitor();
if ($visitor->user_id
&& empty($visitor->Option->use_tfa)
&& \XF::config('enableTfa')
&& $visitor->hasPermission('general', 'requireTfa')
)
{
$reply = $this->message(\XF::phrase('you_must_enable_two_step_to_continue', [
'link' => $this->buildLink('account/two-step')
]));
$reply->setPageParam('skipSidebarWidgets', true);
throw $this->exception($reply);
}
}
public function assertNotSecurityLocked($action)
{
$visitor = \XF::visitor();
if ($visitor->user_id && $visitor->security_lock)
{
switch ($visitor->security_lock)
{
case 'change':
throw $this->exception($this->redirect($this->buildLink('account/security', null, [
'_xfRedirect' => $this->request->getFullRequestUri()
])));
case 'reset':
$existing = $this->em()->find('XF:UserConfirmation', [$visitor->user_id, 'security_lock_reset']);
if ($existing)
{
$reply = $this->message(
\XF::phrase('your_account_is_currently_security_locked_and_awaiting_password_reset', [
'email' => $visitor->email,
'resendLink' => $this->buildLink('security-lock/resend', $visitor)
])
);
$reply->setPageParam('skipSidebarWidgets', true);
throw $this->exception($reply);
}
else
{
/** @var \XF\Service\User\SecurityLockReset $passwordConfirmation */
$passwordConfirmation = $this->service('XF:User\SecurityLockReset', $visitor);
if (!$passwordConfirmation->canTriggerConfirmation($error))
{
throw $this->exception($this->error($error));
}
try
{
$passwordConfirmation->triggerConfirmation();
}
catch (\XF\Db\DuplicateKeyException $e)
{
// Likely a race condition with another tab. We can just ignore it
// as the message will direct them to check their email.
}
$reply = $this->message(\XF::phrase(
'your_account_is_currently_security_locked_need_to_reset_your_password') . ' ' . \XF::phrase('password_reset_request_has_been_emailed_to_you')
);
$reply->setPageParam('skipSidebarWidgets', true);
throw $this->exception($reply);
}
}
}
}
public function assertPolicyAcceptance($action)
{
$options = $this->options();
if (!isset($options->privacyPolicyLastUpdate, $options->termsLastUpdate))
{
return;
}
$request = $this->request;
$requestUri = $request->getFullRequestUri();
$visitor = \XF::visitor();
$privacyLastUpdate = $options->privacyPolicyLastUpdate;
$privacyPolicyUrl = $this->app->container('privacyPolicyUrl');
$termsLastUpdate = $options->termsLastUpdate;
$tosUrl = $this->app->container('tosUrl');
if ($privacyLastUpdate
&& $privacyPolicyUrl
&& $visitor->user_id
&& $visitor->privacy_policy_accepted < $privacyLastUpdate
&& !$visitor->security_lock
)
{
// check if requested route matches privacy policy URL or whitelist to bypass acceptance
if (!empty($options->privacyPolicyUrl['custom'])
&& $this->canBypassPolicyAcceptance(
$options->privacyPolicyForceWhitelist, $privacyPolicyUrl, $requestUri
)
)
{
return;
}
throw $this->exception($this->redirect($this->buildLink('misc/accept-privacy-policy', null, [
'_xfRedirect' => $this->request->getFullRequestUri()
]), ''));
}
else if ($termsLastUpdate
&& $tosUrl
&& $visitor->user_id
&& $visitor->terms_accepted < $termsLastUpdate
&& !$visitor->security_lock
)
{
// check if requested route matches terms URL or whitelist to bypass acceptance
if (!empty($options->tosUrl['custom'])
&& $this->canBypassPolicyAcceptance(
$options->tosForceWhitelist, $tosUrl, $requestUri
)
)
{
return;
}
throw $this->exception($this->redirect($this->buildLink('misc/accept-terms', null, [
'_xfRedirect' => $this->request->getFullRequestUri()
]), ''));
}
}
protected function canBypassPolicyAcceptance($whitelist, $policyUrl, $requestUri)
{
$request = $this->request;
if ($whitelist)
{
$whitelistRoutePaths = preg_split('/\s+/', trim($whitelist), -1, PREG_SPLIT_NO_EMPTY);
}
else
{
$whitelistRoutePaths = [];
}
$whitelistRoutePaths[] = $request->getRoutePathFromUrl($policyUrl);
$whitelistRoutePaths = array_map(function($routePath)
{
return rtrim($routePath, '/') . '/';
}, $whitelistRoutePaths);
$requestRoutePath = $request->getRoutePathFromUrl($requestUri);
return in_array($requestRoutePath, $whitelistRoutePaths, true);
}
public function isEmbeddedImageRequest(): bool
{
return $this->request->isEmbeddedImageRequest();
}
public function assertNotEmbeddedImageRequest()
{
if ($this->isEmbeddedImageRequest())
{
$this->setResponseType('raw');
$view = $this->view('XF:Error\EmbeddedImageRequest');
$view->setResponseCode(406);
throw $this->exception($view);
}
}
public function hasContentPendingApproval()
{
$pendingUntil = $this->session()->hasContentPendingUntil;
return ($pendingUntil && $pendingUntil >= \XF::$time);
}
protected function isDiscouraged()
{
$visitor = \XF::visitor();
if ($visitor->user_id && $visitor->Option->is_discouraged)
{
return true;
}
else
{
$discouragedIps = $this->app()->container('discouragedIps');
$result = \XF\Util\Ip::checkIpsAgainstBinaryRangeList($this->request->getAllIps(), $discouragedIps['data']);
if (is_array($result))
{
/** @var \XF\Repository\Banning $repo */
$repo = $this->repository('XF:Banning');
$matched = $repo->findIpMatchesByRange($result[0], $result[1])
->where('match_type', 'discouraged');
foreach ($matched->fetch() AS $match)
{
$match->fastUpdate('last_triggered_date', time());
}
}
return (bool)$result;
}
}
protected $discourageChecked;
/**
* Discourage the current visitor from remaining on the board by making theirs a bad experience.
*
* @param string $action
*/
protected function discourage($action)
{
if ($this->discourageChecked === true)
{
return;
}
$this->discourageChecked = true;
$options = $this->app()->options();
// random loading delay
if ($options->discourageDelay['max'])
{
usleep(mt_rand($options->discourageDelay['min'], $options->discourageDelay['max']) * 1000000);
}
// random page redirect
if ($options->discourageRedirectChance && mt_rand(0, 100) < $options->discourageRedirectChance)
{
header('Location: ' . ($options->discourageRedirectUrl ? $options->discourageRedirectUrl : $options->boardUrl));
die();
}
// random blank page
if ($options->discourageBlankChance && mt_rand(0, 100) < $options->discourageBlankChance)
{
die();
}
// randomly disable search
if ($options->discourageSearchChance && mt_rand(0, 100) < $options->discourageSearchChance)
{
$options->enableSearch = false;
}
// increase flood check time
if ($options->discourageFloodMultiplier > 1)
{
$options->floodCheckLength = $options->floodCheckLength * $options->discourageFloodMultiplier;
}
}
/**
* Checks whether a IP constraint is matched. The value is cached in the session.
* The IP data should be an array with 2 keys:
* - version: unique identifier of cache revision
* - data: array of IPs to check (grouped by first byte)
*
* If the version differs, the cache value is ignored.
*
* @param array $ipData
* @param string $sessionKey Key to write to/read from in the session
*
* @return bool True if matched, false otherwise
*
* @deprecated Implement directly. Liable to be removed.
*/
protected function getRequestIpConstraintCached(array $ipData, $sessionKey)
{
if (!$ipData || empty($ipData['data']))
{
return false;
}
$result = null;
$session = $this->app()->session();
$sessionValue = $session->{$sessionKey};
if ($sessionValue
&& isset($sessionValue['version'])
&& isset($ipData['version'])
&& $sessionValue['version'] == $ipData['version']
)
{
$result = $sessionValue['result'];
}
if ($result === null)
{
$result = \XF\Util\Ip::checkIpsAgainstBinaryRangeList($this->request->getAllIps(), $ipData['data']);
if ($session)
{
$session->{$sessionKey} = [
'result' => $result,
'version' => $ipData['version'] ?? 1
];
}
return $result;
}
return $result;
}
public function assertNotFlooding($action, $floodingLimit = null)
{
$visitor = \XF::visitor();
if ($visitor->hasPermission('general', 'bypassFloodCheck'))
{
return;
}
/** @var \XF\Service\FloodCheck $floodChecker */
$floodChecker = $this->service('XF:FloodCheck');
$timeRemaining = $floodChecker->checkFlooding($action, $visitor->user_id, $floodingLimit);
if ($timeRemaining)
{
throw $this->exception($this->responseFlooding($timeRemaining));
}
}
public function responseFlooding($floodSeconds)
{
return $this->error(\XF::phrase('must_wait_x_seconds_before_performing_this_action', ['count' => $floodSeconds]));
}
public function isRobot()
{
return boolval($this->request->getRobotName());
}
/**
* @return \XF\Repository\Ip
*/
protected function getIpRepo()
{
return $this->repository('XF:Ip');
}
protected static function getActivityDetailsForContent(
array $activities, $phrase, $pluckParam, \Closure $dataLoader, $fallbackPhrase = null
)
{
$ids = [];
foreach ($activities AS $activity)
{
/** @var \XF\Entity\SessionActivity $activity */
$id = $activity->pluckParam($pluckParam);
if ($id)
{
$ids[$id] = $id;
}
}
if ($ids)
{
$data = $dataLoader($ids);
}
else
{
$data = [];
}
$output = [];
foreach ($activities AS $key => $activity)
{
/** @var \XF\Entity\SessionActivity $activity */
$id = $activity->pluckParam($pluckParam);
$content = $id && isset($data[$id]) ? $data[$id] : null;
if ($content)
{
$output[$key] = [
'description' => $phrase,
'title' => $content['title'],
'url' => $content['url'],
];
}
else if ($id)
{
$output[$key] = $phrase;
}
else
{
$output[$key] = $fallbackPhrase ?: $phrase;
}
}
return $output;
}
/**
* @param \XF\Entity\SessionActivity[] $activities
*/
public static function getActivityDetails(array $activities)
{
return false;
}
}