*/ 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 .= ''; } $success = $session->getFlash('success', null, true); if ($success) { $str .= ''; } $info = $session->getFlash('info', null, true); if ($info) { $str .= ''; } $email = $session->getFlash('email', null, true); if ($email && YII_ENV === 'test') { $str .= ''; } if ($str !== '' && $addBorder) { $str = '
' . $str . '
'; } 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('/\/(?[\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; } }