diff --git a/core/core.services.yml b/core/core.services.yml index 8a24ee7..ae05c2c 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -462,7 +462,7 @@ services: calls: - [setContainer, ['@service_container']] exception_listener: - class: Symfony\Component\HttpKernel\EventListener\ExceptionListener + class: Drupal\Core\EventSubscriber\ExceptionListener tags: - { name: event_subscriber } arguments: [['@exception_controller', execute]] diff --git a/core/lib/Drupal/Core/Controller/ExceptionController.php b/core/lib/Drupal/Core/Controller/ExceptionController.php index 8515af0..f3ca11d 100644 --- a/core/lib/Drupal/Core/Controller/ExceptionController.php +++ b/core/lib/Drupal/Core/Controller/ExceptionController.php @@ -12,7 +12,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpKernel\HttpKernelInterface; -use Symfony\Component\HttpKernel\Exception\FlattenException; +use Symfony\Component\Debug\Exception\FlattenException; use Drupal\Core\ContentNegotiation; @@ -92,6 +92,7 @@ public function on403Html(FlattenException $exception, Request $request) { } $subrequest = Request::create('/' . $path, 'get', array('destination' => $system_path), $request->cookies->all(), array(), $request->server->all()); + $subrequest->attributes->set('_account', $request->attributes->get('_account')); // The active trail is being statically cached from the parent request to // the subrequest, like any other static. Unfortunately that means the @@ -165,6 +166,7 @@ public function on404Html(FlattenException $exception, Request $request) { // @todo The create() method expects a slash-prefixed path, but we store a // normal system path in the site_404 variable. $subrequest = Request::create('/' . $path, 'get', array(), $request->cookies->all(), array(), $request->server->all()); + $subrequest->attributes->set('_account', $request->attributes->get('_account')); // The active trail is being statically cached from the parent request to // the subrequest, like any other static. Unfortunately that means the diff --git a/core/lib/Drupal/Core/EventSubscriber/ExceptionListener.php b/core/lib/Drupal/Core/EventSubscriber/ExceptionListener.php new file mode 100644 index 0000000..94ec3b8 --- /dev/null +++ b/core/lib/Drupal/Core/EventSubscriber/ExceptionListener.php @@ -0,0 +1,88 @@ +getException(); + $request = $event->getRequest(); + + $this->logException($exception, sprintf('Uncaught PHP Exception %s: "%s" at %s line %s', get_class($exception), $exception->getMessage(), $exception->getFile(), $exception->getLine())); + + $request = $this->getRequestClone($exception, $request); + + try { + $response = $event->getKernel() + ->handle($request, HttpKernelInterface::SUB_REQUEST, TRUE); + } + catch (\Exception $e) { + $this->logException($exception, sprintf('Exception thrown when handling an exception (%s: %s)', get_class($e), $e->getMessage()), FALSE); + + // Set handling to false otherwise it wont be able to handle further more. + $handling = FALSE; + + // Re-throw the exception from within HttpKernel as this is a catch-all. + return; + } + + $event->setResponse($response); + + $handling = FALSE; + } + + /** + * Clones the request in case of an exception. + * + * @param \Exception $exception + * The thrown exception. + * @param \Symfony\Component\HttpFoundation\Request $request + * The original request. + * + * @return \Symfony\Component\HttpFoundation\Request + * The cloned request containing the exception and additional information. + */ + protected function getRequestClone(\Exception $exception, Request $request) { + $attributes = array( + '_controller' => $this->controller, + 'exception' => FlattenException::create($exception), + 'logger' => $this->logger instanceof DebugLoggerInterface ? $this->logger : NULL, + 'format' => $request->getRequestFormat(), + // Include the current account. + '_account' => $request->attributes->get('_account') + ); + + $request = $request->duplicate(NULL, NULL, $attributes); + $request->setMethod('GET'); + return $request; + } + +} diff --git a/core/modules/contact/contact.module b/core/modules/contact/contact.module index 2b1d8bf..a1871c2 100644 --- a/core/modules/contact/contact.module +++ b/core/modules/contact/contact.module @@ -86,31 +86,21 @@ function contact_menu() { $items['contact'] = array( 'title' => 'Contact', - 'page callback' => 'contact_site_page', - 'access arguments' => array('access site-wide contact form'), + 'route_name' => 'contact_site_page', 'menu_name' => 'footer', - 'type' => MENU_SUGGESTED_ITEM, - 'file' => 'contact.pages.inc', ); $items['contact/%contact_category'] = array( 'title' => 'Contact category form', 'title callback' => 'entity_page_label', 'title arguments' => array(1), - 'page callback' => 'contact_site_page', - 'page arguments' => array(1), - 'access arguments' => array('access site-wide contact form'), + 'route_name' => 'contact_site_page_category', 'type' => MENU_VISIBLE_IN_BREADCRUMB, - 'file' => 'contact.pages.inc', ); $items['user/%user/contact'] = array( 'title' => 'Contact', - 'page callback' => 'contact_personal_page', - 'page arguments' => array(1), + 'route_name' => 'contact_personal_page', 'type' => MENU_LOCAL_TASK, - 'access callback' => '_contact_personal_tab_access', - 'access arguments' => array(1), 'weight' => 2, - 'file' => 'contact.pages.inc', ); return $items; } diff --git a/core/modules/contact/contact.pages.inc b/core/modules/contact/contact.pages.inc deleted file mode 100644 index b6da2ec..0000000 --- a/core/modules/contact/contact.pages.inc +++ /dev/null @@ -1,106 +0,0 @@ -get('default_category'); - if (isset($categories[$default_category])) { - $category = $categories[$default_category]; - } - // If there are no categories, do not display the form. - else { - if (user_access('administer contact forms')) { - drupal_set_message(t('The contact form has not been configured. Add one or more categories to the form.', array('@add' => url('admin/structure/contact/add'))), 'error'); - return array(); - } - else { - throw new NotFoundHttpException(); - } - } - } - else if ($category->id() == 'personal') { - throw new NotFoundHttpException(); - } - $message = entity_create('contact_message', array( - 'category' => $category->id(), - )); - return Drupal::entityManager()->getForm($message); -} - -/** - * Page callback: Form constructor for the personal contact form. - * - * @param $recipient - * The account for which a personal contact form should be generated. - * - * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException - * - * @see contact_menu() - * @see contact_personal_form_submit() - * - * @ingroup forms - */ -function contact_personal_page($recipient) { - global $user; - - // Check if flood control has been activated for sending e-mails. - if (!user_access('administer contact forms') && !user_access('administer users')) { - contact_flood_control(); - } - - drupal_set_title(t('Contact @username', array('@username' => user_format_name($recipient))), PASS_THROUGH); - - $message = entity_create('contact_message', array( - 'recipient' => $recipient, - 'category' => 'personal', - )); - return Drupal::entityManager()->getForm($message); -} - -/** - * Throws an exception if the current user is not allowed to submit a contact form. - * - * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException - * - * @see contact_site_page() - * @see contact_personal_page() - */ -function contact_flood_control() { - $config = Drupal::config('contact.settings'); - $limit = $config->get('flood.limit'); - $interval = $config->get('flood.interval'); - if (!Drupal::service('flood')->isAllowed('contact', $limit, $interval)) { - drupal_set_message(t("You cannot send more than %limit messages in @interval. Try again later.", array( - '%limit' => $limit, - '@interval' => format_interval($interval), - )), 'error'); - throw new AccessDeniedHttpException(); - } -} diff --git a/core/modules/contact/contact.routing.yml b/core/modules/contact/contact.routing.yml index ba47a6a..ed18b20 100644 --- a/core/modules/contact/contact.routing.yml +++ b/core/modules/contact/contact.routing.yml @@ -1,9 +1,9 @@ contact_category_delete: pattern: 'admin/structure/contact/manage/{contact_category}/delete' defaults: - _entity_form: contact_category.delete + _entity_form: 'contact_category.delete' requirements: - _entity_access: contact_category.delete + _entity_access: 'contact_category.delete' contact_category_list: pattern: '/admin/structure/contact' @@ -15,13 +15,34 @@ contact_category_list: contact_category_add: pattern: '/admin/structure/contact/add' defaults: - _entity_form: contact_category.add + _entity_form: 'contact_category.add' requirements: _permission: 'administer contact forms' contact_category_edit: pattern: '/admin/structure/contact/manage/{contact_category}' defaults: - _entity_form: contact_category.edit + _entity_form: 'contact_category.edit' requirements: - _entity_access: contact_category.update + _entity_access: 'contact_category.update' + +contact_site_page: + pattern: 'contact' + defaults: + _content: '\Drupal\contact\Controller\ContactPageController::contactSitePage' + requirements: + _permission: 'access site-wide contact form' + +contact_site_page_category: + pattern: 'contact/{contact_category}' + defaults: + _content: '\Drupal\contact\Controller\ContactPageController::contactSitePage' + requirements: + _entity_access: 'contact_category.page' + +contact_personal_page: + pattern: 'user/{user}/contact' + defaults: + _content: '\Drupal\contact\Controller\ContactPageController::contactPersonalPage' + requirements: + _access_contact_personal_tab: 'TRUE' diff --git a/core/modules/contact/contact.services.yml b/core/modules/contact/contact.services.yml new file mode 100644 index 0000000..cccd1fd --- /dev/null +++ b/core/modules/contact/contact.services.yml @@ -0,0 +1,6 @@ +services: + access_check.contact_personal: + class: Drupal\contact\Access\ContactPageAccess + tags: + - { name: access_check } + arguments: ['@config.factory', '@user.data'] diff --git a/core/modules/contact/lib/Drupal/contact/Access/ContactPageAccess.php b/core/modules/contact/lib/Drupal/contact/Access/ContactPageAccess.php new file mode 100644 index 0000000..c278349 --- /dev/null +++ b/core/modules/contact/lib/Drupal/contact/Access/ContactPageAccess.php @@ -0,0 +1,97 @@ +contactSettings = $config_factory->get('contact.settings'); + $this->userData = $user_data; + } + + /** + * {@inheritdoc} + */ + public function appliesTo() { + return array('_access_contact_personal_tab'); + } + + /** + * {@inheritdoc} + */ + public function access(Route $route, Request $request) { + $contact_account = $request->attributes->get('user'); + + // Anonymous users cannot have contact forms. + if ($contact_account->isAnonymous()) { + return static::DENY; + } + + $current_account = $request->attributes->get('_account'); + + // Users may not contact themselves. + if ($current_account->id() == $contact_account->id()) { + return static::DENY; + } + + // User administrators should always have access to personal contact forms. + if ($current_account->hasPermission('administer users')) { + return static::ALLOW; + } + + // If requested user has been blocked, do not allow users to contact them. + if ($contact_account->isBlocked()) { + return static::DENY; + } + + // If the requested user has disabled their contact form, do not allow users + // to contact them. + if ($this->userData->get('contact', $contact_account->id(), 'enabled') == 0) { + return static::DENY; + } + // If the requested user did not save a preference yet, deny access if the + // configured default is disabled. + else if (!$this->contactSettings->get('user_default_enabled')) { + return static::DENY; + } + + return $current_account->hasPermission('access user contact forms') ? static::ALLOW : static::DENY; + } + +} diff --git a/core/modules/contact/lib/Drupal/contact/CategoryAccessController.php b/core/modules/contact/lib/Drupal/contact/CategoryAccessController.php index ba0bab7..294ca58 100644 --- a/core/modules/contact/lib/Drupal/contact/CategoryAccessController.php +++ b/core/modules/contact/lib/Drupal/contact/CategoryAccessController.php @@ -22,11 +22,16 @@ class CategoryAccessController extends EntityAccessController { */ public function checkAccess(EntityInterface $entity, $operation, $langcode, AccountInterface $account) { if ($operation == 'delete' || $operation == 'update') { - // Do not allow delete 'personal' category used for personal contact form. - return user_access('administer contact forms', $account) && $entity->id() !== 'personal'; + // Do not allow the 'personal' category to be deleted, as it's used for + // the personal contact form. + return $account->hasPermission('administer contact forms') && $entity->id() !== 'personal'; + } + elseif ($operation == 'page') { + // Do not allow access personal category via site-wide route. + return $account->hasPermission('access site-wide contact form') && $entity->id() !== 'personal'; } else { - return user_access('administer contact forms', $account); + return $account->hasPermission('administer contact forms'); } } diff --git a/core/modules/contact/lib/Drupal/contact/Controller/ContactPageController.php b/core/modules/contact/lib/Drupal/contact/Controller/ContactPageController.php new file mode 100644 index 0000000..3bc2e26 --- /dev/null +++ b/core/modules/contact/lib/Drupal/contact/Controller/ContactPageController.php @@ -0,0 +1,162 @@ +flood = $this->container->get('flood'); + $this->contactSettings = $this->container->get('config.factory')->get('contact.settings'); + $this->entityManager = $this->container->get('plugin.manager.entity'); + $this->contactStorage = $this->entityManager->getStorageController('contact_category'); + $this->urlGenerator = $this->container->get('url_generator'); + } + + /** + * Presents the site-wide contact form. + * + * @param \Drupal\Core\Session\AccountInterface $_account + * The current account. + * @param \Drupal\contact\Plugin\Core\Entity\Category $contact_category + * The contact category to use. + * + * @return array + * The form as render array as expected by drupal_render(). + * + * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * Exception is thrown when user tries to access non existing default + * contact category form. + */ + public function contactSitePage(AccountInterface $_account, Category $contact_category = NULL) { + // Check if flood control has been activated for sending e-mails. + if (!$_account->hasPermission('administer contact forms')) { + $this->contactFloodControl(); + } + + // Use the default category if no category has been passed. + if (empty($contact_category)) { + $default_category = $this->contactSettings->get('default_category'); + $contact_category = $this->contactStorage->load($default_category); + // If there are no categories, do not display the form. + if (empty($contact_category)) { + if ($_account->hasPermission('administer contact forms')) { + drupal_set_message($this->t('The contact form has not been configured. Add one or more categories to the form.', array( + '@add' => $this->urlGenerator->generateFromPath('admin/structure/contact/add'))), 'error'); + return array(); + } + else { + throw new NotFoundHttpException(); + } + } + } + + $message = $this->contactStorage->create(array( + 'category' => $contact_category->id(), + )); + + return $this->entityManager->getForm($message); + } + + /** + * Form constructor for the personal contact form. + * + * @param \Drupal\user\UserInterface $user + * The account for which a personal contact form should be generated. + * @param \Drupal\Core\Session\AccountInterface $_account + * The current user. + * + * @return array + * The personal contact form as render array as expected by drupal_render(). + */ + public function contactPersonalPage(UserInterface $user, AccountInterface $_account) { + // Check if flood control has been activated for sending e-mails. + if (!$_account->hasPermission('administer contact forms') && !$_account->hasPermission('administer users')) { + $this->contactFloodControl(); + } + + $message = $this->contactStorage->create(array( + 'category' => 'personal', + 'recipient' => $user->id(), + )); + + $form = $this->entityManager->getForm($message); + $form['#title'] = $this->t('Contact @username', array('@username' => $user->getUsername())); + return $form; + } + + /** + * Throws an exception if the current user triggers flood control. + * + * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException + */ + protected function contactFloodControl() { + $limit = $this->contactSettings->get('flood.limit'); + $interval = $this->contactSettings->get('flood.interval'); + if (!$this->flood->isAllowed('contact', $limit, $interval)) { + drupal_set_message($this->t('You cannot send more than %limit messages in @interval. Try again later.', array( + '%limit' => $limit, + '@interval' => format_interval($interval), + )), 'error'); + throw new AccessDeniedHttpException(); + } + } + +} diff --git a/core/modules/contact/lib/Drupal/contact/Tests/ContactSitewideTest.php b/core/modules/contact/lib/Drupal/contact/Tests/ContactSitewideTest.php index 56b902e..5987167 100644 --- a/core/modules/contact/lib/Drupal/contact/Tests/ContactSitewideTest.php +++ b/core/modules/contact/lib/Drupal/contact/Tests/ContactSitewideTest.php @@ -44,7 +44,8 @@ function testSiteWideContact() { $this->drupalLogin($admin_user); $flood_limit = 3; - \Drupal::config('contact.settings') + $this->container->get('config.factory') + ->get('contact.settings') ->set('flood.limit', $flood_limit) ->set('flood.interval', 600) ->save(); @@ -89,6 +90,9 @@ function testSiteWideContact() { $this->drupalGet('contact'); $this->assertResponse(200); $this->assertText(t('The contact form has not been configured.')); + // Test access personal category via site-wide contact page. + $this->drupalGet('contact/personal'); + $this->assertResponse(403); // Add categories. // Test invalid recipients. @@ -110,7 +114,8 @@ function testSiteWideContact() { $this->assertRaw(t('Category %label has been added.', array('%label' => $label))); // Check that the category was created in site default language. - $langcode = \Drupal::config('contact.category.' . $id)->get('langcode'); + $langcode = $this->container->get('config.factory') + ->get('contact.category.' . $id)->get('langcode'); $default_langcode = language_default()->id; $this->assertEqual($langcode, $default_langcode); @@ -119,7 +124,8 @@ function testSiteWideContact() { // Test update contact form category. $this->updateCategory($id, $label = $this->randomName(16), $recipients_str = implode(',', array($recipients[0], $recipients[1])), $reply = $this->randomName(30), FALSE); - $config = \Drupal::config('contact.category.' . $id)->get(); + $config = $this->container->get('config.factory') + ->get('contact.category.' . $id)->get(); $this->assertEqual($config['label'], $label); $this->assertEqual($config['recipients'], array($recipients[0], $recipients[1])); $this->assertEqual($config['reply'], $reply); @@ -127,7 +133,10 @@ function testSiteWideContact() { $this->assertRaw(t('Category %label has been updated.', array('%label' => $label))); // Reset the category back to be the default category. - \Drupal::config('contact.settings')->set('default_category', $id)->save(); + $this->container->get('config.factory') + ->get('contact.settings') + ->set('default_category', $id) + ->save(); // Ensure that the contact form is shown without a category selection input. user_role_grant_permissions(DRUPAL_ANONYMOUS_RID, array('access site-wide contact form')); @@ -182,7 +191,8 @@ function testSiteWideContact() { $this->assertText(t('Message field is required.')); // Test contact form with no default category selected. - \Drupal::config('contact.settings') + $this->container->get('config.factory') + ->get('contact.settings') ->set('default_category', '') ->save(); $this->drupalGet('contact'); @@ -202,7 +212,10 @@ function testSiteWideContact() { // Submit contact form one over limit. $this->drupalGet('contact'); $this->assertResponse(403); - $this->assertRaw(t('You cannot send more than %number messages in @interval. Try again later.', array('%number' => \Drupal::config('contact.settings')->get('flood.limit'), '@interval' => format_interval(600)))); + $this->assertRaw(t('You cannot send more than %number messages in @interval. Try again later.', array( + '%number' => $this->container->get('config.factory')->get('contact.settings')->get('flood.limit'), + '@interval' => format_interval(600)) + )); // Test listing controller. $this->drupalLogin($admin_user); @@ -376,7 +389,7 @@ function submitContact($name, $mail, $subject, $id, $message) { $edit['mail'] = $mail; $edit['subject'] = $subject; $edit['message'] = $message; - if ($id == \Drupal::config('contact.settings')->get('default_category')) { + if ($id == $this->container->get('config.factory')->get('contact.settings')->get('default_category')) { $this->drupalPost('contact', $edit, t('Send message')); } else { diff --git a/core/modules/contact/lib/Drupal/contact/Tests/MessageEntityTest.php b/core/modules/contact/lib/Drupal/contact/Tests/MessageEntityTest.php index 49c73d7..10a6efd 100644 --- a/core/modules/contact/lib/Drupal/contact/Tests/MessageEntityTest.php +++ b/core/modules/contact/lib/Drupal/contact/Tests/MessageEntityTest.php @@ -21,7 +21,7 @@ class MessageEntityTest extends DrupalUnitTestBase { * * @var array */ - public static $modules = array('system', 'contact'); + public static $modules = array('system', 'user', 'contact'); public static function getInfo() { return array( diff --git a/core/tests/Drupal/Tests/Core/EventSubscriber/ExceptionListenerTest.php b/core/tests/Drupal/Tests/Core/EventSubscriber/ExceptionListenerTest.php new file mode 100644 index 0000000..a8799bc --- /dev/null +++ b/core/tests/Drupal/Tests/Core/EventSubscriber/ExceptionListenerTest.php @@ -0,0 +1,101 @@ + 'Exception listener', + 'description' => 'Unit test the exception listener', + 'group' => 'System' + ); + } + + protected function setUp() { + $this->exceptionListener = new ExceptionListener('test_controller'); + } + + /** + * Tests the onException method with an account. + * + * @see \Drupal\Core\EventSubscriber\ExceptionListener::onException() + */ + public function testOnException() { + // Setup a response with exception event and call onKernelException. + + $account = $this->getMock('Drupal\Core\Session\AccountInterface'); + $request = new Request(array(), array(), array('_account' => $account)); + $kernel = new TestKernel(); + + + try { + // Store the current error_log, and disable it temporarily to not fail + // on the created exception. + $error_log = ini_set('error_log', file_exists('/dev/null') ? '/dev/null' : 'nul'); + + $exception = new \Exception('Test exception'); + $event = new GetResponseForExceptionEvent($kernel, $request, 'test_request_type', $exception); + $this->exceptionListener->onKernelException($event); + + // Restore the old error_log. + ini_set('error_log', $error_log); + } + catch (\Exception $e) { + $this->assertEquals('Test exception', $e->getMessage()); + } + + // Ensure that the subrequest has the proper account object. + $this->assertSame($account, $kernel->account, 'Account object could not be cloned.'); + } + +} + +/** + * Defines a test kernel used for the test. + */ +class TestKernel implements HttpKernelInterface { + + /** + * The current account. + * + * @var \Drupal\Core\Session\AccountInterface + */ + public $account; + + /** + * {@inheritdoc} + */ + public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = true) { + // Set the account to test in the actual test. + $this->account = $request->attributes->get('_account'); + return new Response('foo'); + } + +}