controllers/Base.php
Rama main · réplica de lectura del repositorio original.
<?php
namespace app\controllers;
use app\models\exceptions\{ApiResponseException, NotFound, Internal, ResponseException};
use app\models\forms\LoginUsernamePasswordForm;
use app\models\http\{HtmlResponse, RedirectResponse, ResponseInterface, RestApiExceptionResponse, RestApiResponse};
use app\components\{Html, ConsultationAccess, HTMLTools, JwtCreator, RequestContext, SecondFactorAuthentication, UrlHelper, yii\Application};
use app\models\settings\{AntragsgruenApp, Layout, Privileges};
use app\models\db\{Amendment, Consultation, Motion, repostory\MotionRepository, Site, User};
use Yii;
use yii\web\{Controller, Request, Response, Session};
/**
* @extends Controller<\app\components\yii\Application>
*/
class Base extends Controller
{
public Layout $layoutParams;
public ?Consultation $consultation = null;
public ?Motion $motion = null;
public ?Amendment $amendment = null;
public ?Site $site = null;
/** @var string */
public $layout = '@app/views/layouts/column1';
/** @var null|bool - currently only null (default) and true (allow not-logged in, e.g. by plugins) are supported. false to come. */
public ?bool $allowNotLoggedIn = null;
public bool $limitedAccessBecauseOfOverride = false;
/**
* @param string $cid the ID of this controller.
* @param Application $module the module that this controller belongs to.
* @param array $config name-value pairs that will be used to initialize the object properties.
*/
public function __construct($cid, $module, $config = [])
{
// Hint: can be overwritten in loadConsultation
$this->layoutParams = new Layout();
parent::__construct($cid, $module, $config);
}
protected function beforeActionAuthorizationHandling(\yii\base\Action $action): void
{
$usernamePasswordForm = new LoginUsernamePasswordForm(RequestContext::getSession(), User::getExternalAuthenticator());
$usernamePasswordForm->onPageView(get_class($this), $action->id);
$tfa = new SecondFactorAuthentication(RequestContext::getSession());
$tfa->onPageView(get_class($this), $action->id);
}
/**
* @throws Internal
* @throws \Exception
* @throws \yii\base\ExitException
* @throws \yii\web\BadRequestHttpException
*/
public function beforeAction($action): bool
{
/** @var Response $response */
$response = Yii::$app->response;
$response->headers->add('X-Xss-Protection', '1');
$response->headers->add('X-Content-Type-Options', 'nosniff');
$response->headers->add('X-Frame-Options', 'sameorigin');
$this->beforeActionAuthorizationHandling($action);
if (!parent::beforeAction($action)) {
return false;
}
$params = $this->getHttpRequest()->resolve();
$appParams = AntragsgruenApp::getInstance();
if ($appParams->updateKey) {
$this->showErrorpage(503, Yii::t('base', 'err_update_mode'));
}
$inManager = (get_class($this) === ManagerController::class);
$inInstaller = (get_class($this) === InstallationController::class);
if ($inInstaller) {
$this->layoutParams->setLayout(Layout::getDefaultLayout());
} elseif ($appParams->siteSubdomain || isset($params[1]['subdomain'])) {
$subdomain = $appParams->siteSubdomain ?? $params[1]['subdomain'];
if (str_starts_with($subdomain, 'xn--')) {
$convertedSubdomain = idn_to_utf8($subdomain);
if ($convertedSubdomain === false) {
$convertedSubdomain = '';
}
$subdomain = $convertedSubdomain;
}
$consultation = $params[1]['consultationPath'] ?? '';
if ($consultation === '' && $this->isGetSet('passConId')) {
$consultation = $this->getHttpRequest()->get('passConId');
}
$this->loadConsultation($subdomain, $consultation);
if ($this->site) {
$this->layoutParams->setLayout($this->site->getSettings()->siteLayout);
} else {
$this->layoutParams->setLayout(Layout::getDefaultLayout());
}
} elseif (!$inManager && !$appParams->multisiteMode) {
$this->layoutParams->setLayout(Layout::getDefaultLayout());
$this->showErrorpage(500, Yii::t('base', 'err_no_site_internal'));
} else {
$this->layoutParams->setLayout(Layout::getDefaultLayout());
}
if ($this->allowNotLoggedIn === true) {
return true;
}
// If re-installing while being logged in in the old installation, the testConsultationPwd below would break the site
if ($inInstaller) {
return true;
}
if (get_class($this) === PagesController::class && in_array($action->id, [PagesController::VIEW_ID_SHOW_PAGE, PagesController::VIEW_ID_CSS])) {
return true;
}
if (get_class($this) === PagesController::class && $action->id === PagesController::VIEW_ID_FILES && $this->consultation) {
if ($this->consultation->getSettings()->logoUrl) {
$logo = urldecode(basename($this->consultation->getSettings()->logoUrl));
if (isset($params[1]['filename']) && $logo && $params[1]['filename'] === $logo) {
return true;
}
}
if (isset($this->site->getSettings()->getStylesheet()->backgroundImage)) {
$bg = urldecode(basename($this->site->getSettings()->getStylesheet()->backgroundImage));
if (isset($params[1]['filename']) && $params[1]['filename'] === $bg) {
return true;
}
}
}
$accessCheck = (new ConsultationAccess($this->consultation))->testForDenyReason(get_class($this), $action->id);
if (isset($accessCheck['limitedAccessBecauseOfOverride'])) {
$this->limitedAccessBecauseOfOverride = $accessCheck['limitedAccessBecauseOfOverride'];
}
if ($accessCheck['denied']) {
if (isset($accessCheck['deniedRedirect'])) {
$this->redirect($accessCheck['deniedRedirect']);
}
return false;
}
return true;
}
public function runAction($id, $params = []): ?string
{
try {
$response = parent::runAction($id, $params);
/** @phpstan-ignore-next-line */
} /** @noinspection PhpRedundantCatchClauseInspection */ catch (ApiResponseException $e) {
$response = new RestApiExceptionResponse($e->getCode(), $e->getMessage());
return $response->renderYii($this->layoutParams, $this->getHttpResponse());
/** @phpstan-ignore-next-line */
} /** @noinspection PhpRedundantCatchClauseInspection */ catch (ResponseException $e) {
return $e->response->renderYii($this->layoutParams, $this->getHttpResponse());
}
if (is_string($response)) {
return $response;
}
if (is_object($response) && is_a($response, ResponseInterface::class)) {
/** @var ResponseInterface $response */
return $response->renderYii($this->layoutParams, $this->getHttpResponse());
}
return $response;
}
/**
* @param array|string $url
* @param int $statusCode
* @throws \yii\base\ExitException
*/
public function redirect($url, $statusCode = 302): never
{
parent::redirect($url, $statusCode);
Yii::$app->end();
}
protected function getHttpRequest(): Request
{
/** @var Request $request */
$request = Yii::$app->request;
return $request;
}
protected function getHttpResponse(): Response
{
/** @var Response $response */
$response = Yii::$app->response;
return $response;
}
protected function getHttpSession(): Session
{
return RequestContext::getSession();
}
protected function getHttpMethod(): string
{
return $this->getHttpRequest()->method;
}
protected function getHttpHeader(string $headerName): ?string
{
return $this->getHttpRequest()->headers->get($headerName);
}
protected function getPostBody(): string
{
return $this->getHttpRequest()->getRawBody();
}
protected function isPostSet(string $name): bool
{
$post = $this->getPostValues();
return isset($post[$name]);
}
protected function isGetSet(string $name): bool
{
$get = $this->getHttpRequest()->get();
return isset($get[$name]);
}
public function isRequestSet(string $name): bool
{
return $this->isPostSet($name) || $this->isGetSet($name);
}
public function getRequestValue(string $name, mixed $default = null): mixed
{
$post = $this->getPostValues();
if (isset($post[$name])) {
return $post[$name];
}
$get = $this->getHttpRequest()->get();
if (isset($get[$name])) {
return $get[$name];
}
return $default;
}
public function getPostValue(string $name, mixed $default = null): mixed
{
return $this->getHttpRequest()->post($name, $default);
}
public function getPostValues(): array
{
/** @var array $post */
$post = $this->getHttpRequest()->post();
return $post;
}
public function renderContentPage(string $pageKey): HtmlResponse
{
if ($this->consultation) {
$admin = User::havePrivilege($this->consultation, Privileges::PRIVILEGE_CONTENT_EDIT, null);
} else {
$user = User::getCurrentUser();
$admin = ($user && in_array($user->id, $this->getParams()->adminUserIds));
}
return new HtmlResponse($this->render(
'@app/views/pages/contentpage',
[
'pageKey' => $pageKey,
'admin' => $admin,
]
));
}
/**
* @param string $view
* @param array $params
* @return string
*/
public function render($view, $params = array())
{
$params = array_merge(
[
'consultation' => $this->consultation,
'reducedNavigation' => $this->limitedAccessBecauseOfOverride,
],
$params
);
return parent::render($view, $params);
}
/**
* @throws ApiResponseException
*/
public function handleRestHeaders(array $allowedMethods, bool $alwaysEnabled = false): void
{
$this->layoutParams->setFallbackLayoutIfNotInitializedYet();
$this->layoutParams->robotsNoindex = true;
if (JwtCreator::isCurrentTokenRestrictedToOtherSite($this->site)) {
throw new ApiResponseException('Token not valid for this site', 401);
}
if (!$this->site->getSettings()->apiEnabled && !$alwaysEnabled && !User::getCurrentUser()) {
throw new ApiResponseException('Public API disabled', 403);
}
if ($this->consultation && ($this->consultation->urlPath === null || $this->consultation->dateDeletion)) {
throw new ApiResponseException('Consultation not found', 404);
}
if ($this->consultation && $this->consultation->getSettings()->maintenanceMode && !$alwaysEnabled) {
if (!User::havePrivilege($this->consultation, Privileges::PRIVILEGE_CONSULTATION_SETTINGS, null)) {
throw new ApiResponseException('Consultation in maintenance mode', 404);
}
}
/** @var Response $response */
$response = Yii::$app->response;
if ($this->site->getSettings()->apiCorsOrigins) {
if (in_array('*', $this->site->getSettings()->apiCorsOrigins)) {
$response->headers->add('Access-Control-Allow-Origin', '*');
} elseif ($this->getHttpRequest()->origin && in_array($this->getHttpRequest()->origin, $this->site->getSettings()->apiCorsOrigins)) {
$response->headers->add('Access-Control-Allow-Origin', $this->getHttpRequest()->origin);
}
}
$response->headers->add('Access-Control-Allow-Methods', implode(', ', $allowedMethods));
if ($this->getHttpMethod() === 'OPTIONS') {
Yii::$app->end();
}
if (!in_array($this->getHttpMethod(), $allowedMethods)) {
throw new ApiResponseException('Method not allowed', 405);
}
}
public function returnRestResponseFromException(\Exception $exception): RestApiResponse {
return new RestApiExceptionResponse($exception->getCode() > 0 ? $exception->getCode() : 500, $exception->getMessage());
}
public function getParams(): AntragsgruenApp
{
/** @var AntragsgruenApp $app */
$app = Yii::$app->params;
return $app;
}
public function forceLogin(): void
{
if (RequestContext::getYiiUser()->getIsGuest()) {
$loginUrl = UrlHelper::createUrl(['/user/login', 'backUrl' => $this->getHttpRequest()->url]);
$this->redirect($loginUrl);
}
}
public function showErrors(bool $addBorder = false): string
{
$session = $this->getHttpSession();
if (!$session->isActive) {
return '';
}
$str = '';
$error = $session->getFlash('error', null, true);
if ($error) {
$str .= '<div class="alert alert-danger" role="alert">
<span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
<span class="sr-only">' . Yii::t('base', 'aria_error') . ':</span>
' . nl2br(Html::encode($error)) . '
</div>';
}
$success = $session->getFlash('success', null, true);
if ($success) {
$str .= '<div class="alert alert-success" role="alert">
<span class="glyphicon glyphicon-ok-sign" aria-hidden="true"></span>
<span class="sr-only">' . Yii::t('base', 'aria_success') . ':</span>
' . Html::encode($success) . '
</div>';
}
$info = $session->getFlash('info', null, true);
if ($info) {
$str .= '<div class="alert alert-info" role="alert">
<span class="glyphicon glyphicon glyphicon-info-sign" aria-hidden="true"></span>
<span class="sr-only">' . Yii::t('base', 'aria_info') . ':</span>
' . Html::encode($info) . '
</div>';
}
$email = $session->getFlash('email', null, true);
if ($email && YII_ENV === 'test') {
$str .= '<div class="alert alert-info" role="alert">
<span class="glyphicon glyphicon glyphicon-info-sign" aria-hidden="true"></span>
<span class="sr-only">' . Yii::t('base', 'aria_info') . ':</span>
' . Html::encode($email) . '
</div>';
}
if ($str !== '' && $addBorder) {
$str = '<div class="content">' . $str . '</div>';
}
return $str;
}
/**
* @throws ApiResponseException
* @throws \yii\base\ExitException
*/
protected function showErrorpage(int $status, ?string $message): never
{
if (RequestContext::isRestApiRequest()) {
// The API answers in JSON, and an API client can do nothing with an HTML page anyway.
// Rendering the layout would also write to the session, which the API must not do: with
// enableCsrfCookie switched off for it (see RestBase::beforeAction()), the CSRF token
// that Html::csrfMetaTags() puts into every page is stored in the session instead.
// Base::runAction() turns this into a JSON response of the same status.
throw new ApiResponseException(trim(HTMLTools::toPlainText($message ?? '')), $status);
}
/** @var \yii\web\Application $app */
$app = Yii::$app;
$response = $app->response;
$this->layoutParams->setFallbackLayoutIfNotInitializedYet();
$this->layoutParams->robotsNoindex = true;
$response->statusCode = $status;
$response->content = $this->render(
'@app/views/errors/error',
[
'httpStatus' => $status,
'message' => $message,
'name' => 'Error',
]
);
$app->end();
}
protected function consultationNotFound(): never
{
$url = Html::encode($this->getParams()->domainPlain);
$message = str_replace('%URL%', $url, Yii::t('base', 'err_cons_404'));
$this->showErrorpage(404, $message);
}
/**
* @throws ResponseException
* @throws Internal
*/
protected function checkConsistency(?Motion $checkMotion = null, ?Amendment $checkAmendment = null, bool $throwExceptions = false): void
{
$consultationPath = strtolower($this->consultation->urlPath);
$subdomain = strtolower($this->site->subdomain);
if (strtolower($this->consultation->site->subdomain) !== $subdomain) {
if ($throwExceptions) {
throw new Internal(Yii::t('base', 'err_cons_not_site'), 400);
}
$this->getHttpSession()->setFlash("error", Yii::t('base', 'err_cons_not_site'));
throw new ResponseException(new RedirectResponse(UrlHelper::homeUrl()));
}
if (is_object($checkMotion) && strtolower($checkMotion->getMyConsultation()->urlPath) !== $consultationPath) {
if ($throwExceptions) {
throw new Internal(Yii::t('motion', 'err_not_found'), 404);
}
$this->getHttpSession()->setFlash('error', Yii::t('motion', 'err_not_found'));
throw new ResponseException(new RedirectResponse(UrlHelper::homeUrl()));
}
if ($checkAmendment !== null && ($checkMotion === null || $checkAmendment->motionId !== $checkMotion->id)) {
if ($throwExceptions) {
throw new Internal(Yii::t('base', 'err_amend_not_consult'), 400);
}
$this->getHttpSession()->setFlash('error', Yii::t('base', 'err_amend_not_consult'));
throw new ResponseException(new RedirectResponse(UrlHelper::homeUrl()));
}
if ($checkAmendment !== null && ($checkAmendment->amendingAmendmentId && !$checkAmendment->amendedAmendment)) {
$this->getHttpSession()->setFlash('error', Yii::t('base', 'err_amend_no_parent'));
throw new ResponseException(new RedirectResponse(UrlHelper::homeUrl()));
}
}
private function getConsultationUrlFromBackLink(string $backLink): string
{
preg_match('/\/(?<con>[\w_-]+)(\/.*)?$/siu', $this->getRequestValue('backUrl'), $matches);
if (!isset($matches['con'])) {
return '';
}
$consultation = Consultation::findOne(['urlPath' => $matches['con'], 'siteId' => $this->site->id]);
if ($consultation) {
return $consultation->urlPath;
} else {
return '';
}
}
/**
* @throws Internal
* @throws \yii\base\ExitException
*/
public function loadConsultation(string $subdomain, string $consultationId = '', ?Motion $checkMotion = null, ?Amendment $checkAmendment = null): ?Consultation
{
if (is_null($this->site)) {
$this->site = Site::findOne(['subdomain' => $subdomain]);
UrlHelper::setCurrentSite($this->site);
}
if ($this instanceof ConsultationController && $this->action->id === ConsultationController::VIEW_ID_HOME) {
foreach (AntragsgruenApp::getActivePlugins() as $plugin) {
if ($plugin::hasSiteHomePage()) {
return null;
}
}
}
if (is_null($this->site) || $this->site->status === Site::STATUS_DELETED || $this->site->dateDeletion !== null) {
$this->consultationNotFound();
}
if ($consultationId === '' && $this instanceof UserController && $this->action->id === UserController::VIEW_ID_LOGIN_LOGIN && $this->getRequestValue('backUrl')) {
$consultationId = $this->getConsultationUrlFromBackLink($this->getRequestValue('backUrl'));
}
if ($consultationId === '') {
$consultationId = $this->site->currentConsultation->urlPath ?? '';
}
if (is_null($this->consultation)) {
$this->consultation = Consultation::findOne(['urlPath' => $consultationId, 'siteId' => $this->site->id]);
if ($this->consultation && $this->consultation->getSettings()->getSpecializedLayoutClass()) {
/** @var Layout $layoutClass */
$layoutClass = $this->consultation->getSettings()->getSpecializedLayoutClass();
$this->layoutParams = new $layoutClass();
}
}
if (is_null($this->consultation) || $this->consultation->dateDeletion !== null) {
$this->layoutParams->setLayout($this->site->getSettings()->siteLayout);
$this->consultationNotFound();
} else {
Consultation::setCurrent($this->consultation);
}
UrlHelper::setCurrentConsultation($this->consultation);
$this->layoutParams->setConsultation($this->consultation);
$this->checkConsistency($checkMotion, $checkAmendment);
return $this->consultation;
}
protected function guessRedirectByPrefix(string $prefix): ?string
{
$motion = Motion::findOne([
'consultationId' => $this->consultation->id,
'titlePrefix' => $prefix
]);
if ($motion && $motion->isReadable()) {
return $motion->getLink();
}
/** @var Amendment|null $amendment */
$amendment = Amendment::find()->joinWith('motionJoin')->where([
'motion.consultationId' => $this->consultation->id,
'amendment.titlePrefix' => $prefix,
])->one();
if ($amendment && $amendment->isReadable()) {
return $amendment->getLink();
}
return null;
}
protected function getMotionWithCheck(string $motionSlug, bool $throwExceptions = false): Motion
{
$motion = MotionRepository::getMotionByIdOrSlug($this->consultation, $motionSlug);
if (!$motion) {
if ($throwExceptions) {
throw new NotFound('Motion not found', 404);
}
$redirect = $this->guessRedirectByPrefix($motionSlug);
if ($redirect) {
$this->redirect($redirect);
} else {
$this->getHttpSession()->setFlash('error', Yii::t('motion', 'err_not_found'));
$this->redirect(UrlHelper::createUrl('/consultation/index'));
}
}
$this->checkConsistency($motion, null, $throwExceptions);
return $motion;
}
protected function getAmendmentWithCheck(string $motionSlug, int $amendmentId, ?string $redirectView = null, bool $throwExceptions = false): Amendment
{
$motion = $this->consultation->getMotion($motionSlug);
$amendment = $this->consultation->getAmendment($amendmentId);
if (!$amendment || !$motion) {
if ($throwExceptions) {
throw new Internal(Yii::t('amend', 'err_not_found'), 404);
}
$this->redirect(UrlHelper::createUrl('/consultation/index'));
}
if ($amendment->motionId !== $motion->id && $amendment->getMyConsultation()->id === $motion->consultationId) {
if ($throwExceptions) {
throw new Internal(Yii::t('base', 'err_amend_not_consult'), 404);
}
if ($redirectView) {
$this->redirect(UrlHelper::createAmendmentUrl($amendment, $redirectView));
}
}
$this->checkConsistency($motion, $amendment, $throwExceptions);
return $amendment;
}
}