Commit 5bace608 authored by Andrei Ivnitskii's avatar Andrei Ivnitskii
Browse files

feat: #3633610 Add CSRF tokens to links

By: ivnish
parent 43850425
Loading
Loading
Loading
Loading
Loading
+1 −0
Changes for fasttoggle.permissions.yml: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -4,4 +4,5 @@ administer fasttoggle:

use fasttoggle:
  title: 'Use Fasttoggle'
  description: 'Toggle publication status, promotion and stickiness of content the user can update.'
  restrict access: FALSE
+4 −0
Changes for fasttoggle.routing.yml: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -12,3 +12,7 @@ fasttoggle.toggle:
    _controller: '\Drupal\fasttoggle\Controller\FasttoggleController::toggle'
  requirements:
    _permission: 'use fasttoggle'
    _csrf_token: 'TRUE'
    entity_type: 'node|comment'
    entity_id: '\d+'
    action: 'status|promote|sticky'
+190 −104
Changes for src/Controller/FasttoggleController.php: 190 added lines, 104 removed lines.
Original line number Diff line number Diff line
@@ -2,153 +2,239 @@

namespace Drupal\fasttoggle\Controller;

use Drupal\comment\CommentInterface;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Access\CsrfTokenGenerator;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Config\Entity\ThirdPartySettingsInterface;
use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Url;
use Drupal\node\NodeInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;

/**
 * Route controller for Fasttoggle.
 */
class FasttoggleController extends ControllerBase {

  /**
   * Actions supported per entity type.
   */
  private const ACTIONS = [
    'node' => ['status', 'promote', 'sticky'],
    'comment' => ['status'],
  ];

  public function __construct(
    protected TimeInterface $time,
    protected CsrfTokenGenerator $csrfToken,
  ) {}

  /**
   * Toggle a setting.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The request object.
   * @param string $entity_type
   *   The entity type ID of the entity to toggle.
   * @param string $entity_id
   *   The entity ID of the entity to toggle.
   * @param string $action
   *   The action to toggle (status, promote or sticky).
   *
   * @return \Drupal\Core\Ajax\AjaxResponse
   *   The AJAX response with the replacement link.
   *
   * @throws \Symfony\Component\HttpKernel\Exception\HttpException
   *   When the request is invalid or access is denied.
   */
  public function toggle(Request $request) {
    $parts = explode('/', $request->getPathInfo());
    $index = array_search('fasttoggle', $parts);
    $type = $parts[$index + 1];
    $id = $parts[$index + 2];
    $action = $parts[$index + 3];
    $config = $this->config('fasttoggle.settings');
    $label_style = $config->get('label_style');

    if (count($parts) !== 4 && count($parts) !== 5 && count($parts) !== 6) {
      $response = new Response();
      $response->setContent('Incorrect number of parameters');
      $response->setStatusCode(400);
      return $response;
  public function toggle(string $entity_type, string $entity_id, string $action): AjaxResponse {
    $allowed_actions = self::ACTIONS[$entity_type] ?? [];
    if (!$allowed_actions) {
      throw new BadRequestHttpException('Invalid entity type.');
    }
    if (!in_array($action, $allowed_actions, TRUE)) {
      throw new BadRequestHttpException('Invalid action.');
    }

    $entity = $this->entityTypeManager()->getStorage($entity_type)->load($entity_id);
    if (!$entity instanceof ContentEntityInterface) {
      throw new BadRequestHttpException('Invalid entity ID.');
    }

    $link_title = '';
    $selector = '';
    $data = '';
    if (!$entity->access('update', $this->currentUser())) {
      throw new AccessDeniedHttpException();
    }

    switch ($type) {
      case 'node':
        $storage_handler = $this->entityTypeManager()->getStorage('node');
        /** @var \Drupal\node\NodeInterface $node */
        $node = $storage_handler->load($id);
    if (!$entity->get($action)->access('edit', $this->currentUser())) {
      throw new AccessDeniedHttpException();
    }

        if (!$node) {
          $response = new Response();
          $response->setContent('Invalid entity ID.');
          $response->setStatusCode(400);
          return $response;
    if (!$this->isToggleEnabled($entity, $action)) {
      throw new AccessDeniedHttpException();
    }

        switch ($action) {
          case 'status':
            $status = $node->isPublished();
            switch ($label_style) {
              case 0:
                !$node->isPublished() ? $link_title = $this->t('Published') : $link_title = $this->t('Not published');
                break;
    $label_style = (int) $this->config('fasttoggle.settings')->get('label_style');

              case 1:
                !$node->isPublished() ? $link_title = $this->t('Unpublish') : $link_title = $this->t('Publish');
                break;
    if ($entity instanceof NodeInterface) {
      $this->applyNodeToggle($entity, $action);
      [$selector, $link_title] = $this->buildNodeReplacement($entity, $action, $label_style);
    }
    else {
      $this->applyCommentToggle($entity, $action);
      [$selector, $link_title] = $this->buildCommentReplacement($entity, $action, $label_style);
    }
            $status ? $node->setUnpublished() : $node->setPublished();
            $selector = '.fasttoggle-node-status';
            $data = '<a href="/fasttoggle/node/' . $id . '/status" class="use-ajax fasttoggle-node-status">' . $link_title . '</a>';
            break;

          case 'promote':
            $promote = $node->isPromoted();
            switch ($label_style) {
              case 0:
                !$node->isPromoted() ? $link_title = $this->t('Promoted') : $link_title = $this->t('Not promoted');
                break;
    $this->getLogger('fasttoggle')->notice('@entity_type @id: "@action" toggled to a new state by user @uid.', [
      '@entity_type' => $entity_type,
      '@id' => $entity_id,
      '@action' => $action,
      '@uid' => $this->currentUser()->id(),
    ]);

              case 1:
                !$node->isPromoted() ? $link_title = $this->t('Demote') : $link_title = $this->t('Promote');
                break;
    $response = new AjaxResponse();
    $response->addCommand(new ReplaceCommand($selector, $this->buildLinkMarkup($entity_type, $entity->id(), $action, $link_title), NULL));
    return $response;
  }
            $node->setPromoted(!$promote);
            $selector = '.fasttoggle-node-promote';
            $data = '<a href="/fasttoggle/node/' . $id . '/promote" class="use-ajax fasttoggle-node-promote">' . $link_title . '</a>';
            break;

          case 'sticky':
            $sticky = $node->isSticky();
            switch ($label_style) {
              case 0:
                !$node->isSticky() ? $link_title = $this->t('Sticky') : $link_title = $this->t('Not sticky');
  /**
   * Applies a toggle to a node and saves it as a new revision.
   *
   * @param \Drupal\node\NodeInterface $node
   *   The node to toggle.
   * @param string $action
   *   The action to toggle.
   */
  private function applyNodeToggle(NodeInterface $node, string $action): void {
    $is_on = match ($action) {
      'status' => $node->isPublished(),
      'promote' => $node->isPromoted(),
      'sticky' => $node->isSticky(),
    };

    switch ($action) {
      case 'status':
        $is_on ? $node->setUnpublished() : $node->setPublished();
        break;

              case 1:
                !$node->isSticky() ? $link_title = $this->t('Make not sticky') : $link_title = $this->t('Make sticky');
      case 'promote':
        $node->setPromoted(!$is_on);
        break;
            }
            $node->setSticky(!$sticky);
            $selector = '.fasttoggle-node-sticky';
            $data = '<a href="/fasttoggle/node/' . $id . '/sticky" class="use-ajax fasttoggle-node-sticky">' . $link_title . '</a>';

      case 'sticky':
        $node->setSticky(!$is_on);
        break;
    }

    $node->setNewRevision(TRUE);
    $node->setRevisionUserId((int) $this->currentUser()->id());
    $node->setRevisionCreationTime($this->time->getRequestTime());
    $node->setRevisionLogMessage(sprintf('Fasttoggle: %s toggled.', $action));
    $node->save();
  }

        $response = new AjaxResponse();
        $response->addCommand(new ReplaceCommand($selector, $data, NULL));
        return $response;
  /**
   * Applies a toggle to a comment and saves it.
   *
   * @param \Drupal\comment\CommentInterface $comment
   *   The comment to toggle.
   * @param string $action
   *   The action to toggle.
   */
  private function applyCommentToggle(CommentInterface $comment, string $action): void {
    $comment->isPublished() ? $comment->setUnpublished() : $comment->setPublished();
    $comment->save();
  }

      case 'comment':
        $storage_handler = $this->entityTypeManager()->getStorage('comment');
        /** @var \Drupal\comment\CommentInterface $comment */
        $comment = $storage_handler->load($id);
  /**
   * Returns the selector and link title for a node replacement link.
   */
  private function buildNodeReplacement(NodeInterface $node, string $action, int $label_style): array {
    $is_on_after = match ($action) {
      'status' => $node->isPublished(),
      'promote' => $node->isPromoted(),
      'sticky' => $node->isSticky(),
    };
    return [
      '.fasttoggle-node-' . $action,
      $this->linkLabel($action, $is_on_after, $label_style),
    ];
  }

        if (!$comment) {
          $response = new Response();
          $response->setContent('Invalid entity ID.');
          $response->setStatusCode(400);
          return $response;
  /**
   * Returns the selector and link title for a comment replacement link.
   */
  private function buildCommentReplacement(CommentInterface $comment, string $action, int $label_style): array {
    return [
      '.fasttoggle-comment-' . $comment->id() . '-' . $action,
      $this->linkLabel($action, $comment->isPublished(), $label_style),
    ];
  }

        if ($action === 'status') {
          $status = $comment->isPublished();
          switch ($label_style) {
            case 0:
              !$comment->isPublished() ? $link_title = $this->t('Published') : $link_title = $this->t('Not published');
              break;
  /**
   * Checks whether the action is enabled for the entity bundle.
   */
  private function isToggleEnabled(ContentEntityInterface $entity, string $action): bool {
    $bundle_entity_type_id = $this->entityTypeManager()
      ->getDefinition($entity->getEntityTypeId())
      ->getBundleEntityType();

            case 1:
              !$comment->isPublished() ? $link_title = $this->t('Unpublish') : $link_title = $this->t('Publish');
              break;
          }
          $status ? $comment->setUnpublished() : $comment->setPublished();
          $class = 'fasttoggle-comment-' . $id . '-status';
          $selector = '.' . $class;
          $data = '<a href="/fasttoggle/comment/' . $id . '/status" class="use-ajax ' . $class . '">' . $link_title . '</a>';
    if (!$bundle_entity_type_id) {
      return FALSE;
    }

        $comment->save();
    $bundle = $this->entityTypeManager()
      ->getStorage($bundle_entity_type_id)
      ->load($entity->bundle());

        $response = new AjaxResponse();
        $response->addCommand(new ReplaceCommand($selector, $data, NULL));
        return $response;
    return $bundle instanceof ThirdPartySettingsInterface
      && (bool) $bundle->getThirdPartySetting('fasttoggle', $action, 0);
  }

      default:
        $response = new Response();
        $response->setContent('Invalid entity type.');
        $response->setStatusCode(400);
        return $response;
  /**
   * Builds the replacement link markup including a fresh CSRF token.
   *
   * @param string $entity_type
   *   The entity type ID.
   * @param int $entity_id
   *   The entity ID.
   * @param string $action
   *   The action.
   * @param string $link_title
   *   The link title.
   *
   * @return string
   *   The link HTML.
   */
  private function buildLinkMarkup(string $entity_type, int $entity_id, string $action, string $link_title): string {
    $url = Url::fromRoute('fasttoggle.toggle', [
      'entity_type' => $entity_type,
      'entity_id' => $entity_id,
      'action' => $action,
    ])->toString();
    $class = $entity_type === 'node' ? 'fasttoggle-node-' . $action : 'fasttoggle-comment-' . $entity_id . '-' . $action;

    return '<a href="' . $url . '" class="use-ajax ' . $class . '">' . $link_title . '</a>';
  }

  /**
   * Returns the link title describing the given state of an entity.
   */
  private function linkLabel(string $action, bool $is_on, int $label_style): string {
    if ($label_style === 0) {
      return (string) match ($action) {
        'status' => $is_on ? $this->t('Published') : $this->t('Not published'),
        'promote' => $is_on ? $this->t('Promoted') : $this->t('Not promoted'),
        'sticky' => $is_on ? $this->t('Sticky') : $this->t('Not sticky'),
      };
    }

    return (string) match ($action) {
      'status' => $is_on ? $this->t('Unpublish') : $this->t('Publish'),
      'promote' => $is_on ? $this->t('Demote') : $this->t('Promote'),
      'sticky' => $is_on ? $this->t('Make not sticky') : $this->t('Make sticky'),
    };
  }

}
+13 −5
Changes for src/Hook/FasttoggleHooks.php: 13 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -26,9 +26,9 @@ final class FasttoggleHooks {
  use StringTranslationTrait;

  public function __construct(
    protected readonly ConfigFactoryInterface $configFactory,
    protected readonly AccountProxyInterface $currentUser,
    protected readonly EntityTypeManagerInterface $entityTypeManager,
    protected ConfigFactoryInterface $configFactory,
    protected AccountProxyInterface $currentUser,
    protected EntityTypeManagerInterface $entityTypeManager,
  ) {}

  /**
@@ -91,7 +91,11 @@ final class FasttoggleHooks {

    return [
      'title' => $link_title,
      'url' => Url::fromUserInput('/fasttoggle/node/' . $entity->id() . '/' . $toggle),
      'url' => Url::fromRoute('fasttoggle.toggle', [
        'entity_type' => 'node',
        'entity_id' => $entity->id(),
        'action' => $toggle,
      ]),
      'attributes' => [
        'class' => [
          'use-ajax',
@@ -125,7 +129,11 @@ final class FasttoggleHooks {

    return [
      'title' => $link_title,
      'url' => Url::fromUserInput('/fasttoggle/comment/' . $entity->id() . '/' . $toggle),
      'url' => Url::fromRoute('fasttoggle.toggle', [
        'entity_type' => 'comment',
        'entity_id' => $entity->id(),
        'action' => $toggle,
      ]),
      'attributes' => [
        'class' => [
          'use-ajax',
+406 −29

File changed.

Preview size limit exceeded, changes collapsed.