Recent Blog Posts

How to define a local task in Drupal 8

By Ronald van Belzen | June 2, 2018

A local task in Drupal is a callback displayed as a tab. A local task must have a parent item in order for the tab to be rendered.

A well-known example that is part of Drupal core are the local taks for comments with the menu titles "Published comments" and "Unapproved comments". To demonstrate I am going to add an extra local task to those of comments. Let's say I made a new view that will display spam comments and in the advanced settings I have given that view the machine name "page_comment_spam".

The first step would be to create the routing to that view. In the routing the machine name of the view is given to the defaults _view parameter.

# mycomment.routing.yml

mycomment.admin_comment_spam:
  path: /admin/content/comment/spam
  defaults: 
    _title: 'Comment spam'
    _view: page_comment_spam
  requirements:
    _permission: 'administer comments'

This routing defines the callback I will need to define the local task. Instead of a view, I could also have define a controller function (with a defaults _controller parameter) or a form (with a defaults _form parameter), which is used more often. For this example it does not really make a difference.

The definition for the local task is as follows.

# mycomment.links.tasks.yml

mycomment.admin_comment_spam:
  title: 'Spam comments'
  route_name: mycomment.admin_comment_spam
  class: Drupal\mycomment\Plugin\Menu\LocalTask\SpamComments
  parent_id: comment.admin
  weight: 10

I hope that the fact that I named the routing name and the task name the same, does not confuse you in thinking that it needs to be the same name. It does not need to be.

Special in the above is that a class is being defined. This class definition is optional. I included it here to demonstrate how you can dynamically change the title of the tab with the help of a local task plugin.

What is important is that I defined the parent_id the same as the existing local tasks for comments, and that I defined the route_name to be used for the callback of the tab. I added some weight to the definition to make sure the tab is displayed to the right of the existing local tasks.

The plugin definition I adapted from the one being used in the (core) comments module. It adds a count to the tab link equal to the number of comments that will be displayed in the view.

Checking existing users with SFS

By Ronald van Belzen | May 27, 2018

At the moment that you start using Stop Forum Spam some of your subscribers may be known spammers at stopforumspam.com. They may have been dormant for some to time and start spamming when you least expect it. Also some subscribers to your site may have become known as spammers after they registered.

For this reason it may be wise to check your subscribers against the stopforumspam.com database. This is what the function checkUsers() of the SfsRequest class.

/* /src/SfsRequest */ 

 /**
   * Check registered user accounts of being spammers at www.stopforumspam.com.
   */
  public function checkUsers() {
    if (!$this->config->get('sfs_cron_job')) {
      return FALSE;
    }
    $lastUid = $this->config->get('sfs_cron_last_uid');
    $limit = $this->config->get('sfs_cron_account_limit');
    if ($limit > 0) {
      $query = $this->connection->select('users', 'e');
      $query->fields('e', ['uid']);
      $query->condition('uid', $lastUid, '>');
      $query->range(0, $limit);
      $query->orderBy('uid', 'ASC');
      $uids = $query->execute()->fetchCol();
      foreach ($uids as $uid) {
        $lastUid = $uid;
        $user = User::load($uid);
        $include = ($user->isActive() || $this->config->get('sfs_cron_blocked_accounts'));
        if ($include && !$user->hasPermission('exclude from sfs scans') && $this->userIsSpammer($user)) {
          try {
            $user->block();
            $user->save();
            $this->log->notice('User acount @uid has been disabled.', ['@uid' => $uid]);
          }
          catch (EntityStorageException $e) {
            $this->log->error('Failed to disable user acount @uid: @error', ['@uid' => $uid, '@error' => $e->getMessage()]);
          }
        }
      }
      $this->config->set('sfs_cron_last_uid', $lastUid);
      $this->config->save();
    }
    return TRUE;
  }

The function does not scan all subscribers in one run, but scans a limited amount of subscribers that the configuration setting 'sfs_cron_account_limit' allows. Inactive users can be excluded from the scan and users with the permission 'exclude from scans' are skipped too. User accounts of known spammers are disabled.

The actual check is done by the function userIsSpammer() for each individual user.

Reporting spammers to SFS

By Ronald van Belzen | May 17, 2018

The next step is reporting spam to stopforumspam.com. This will be an action initiated by a maintainer who has spotted spam, preferably by one click on a button. This action will need to be handled by the software by sending the report to stopforumspam.com. We we look at the latter first and adding buttons to start the report after that.

As an example we concentrate on comments. The function that needs to be called (commentReport()) first checks whether there is a token (or api key) defined. It also checks whether the user is anonymous. You cannot report an anonymous comment, since stopforumspam.com requires you to fill in name, e-mail address and ip address to report spam and for an anonymous comment post you only have the ip address.

Blocking spammers with SFS

By Ronald van Belzen | May 13, 2018

Let's focus our attention on nodes. Core functionality does not save the IP address of the user together with the node when it is created, so we need to introduce that to our module. The most straightforward approach would be to save the IP address in our own database table. For that I need to introduce a database table (sfs_hostname) by defining a new entity called SfsHostname.

<?php
/* /src/Entity/SfsHostname.php */

namespace Drupal\sfs\Entity;

use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;

/**
 * Defines the sfs hostname entity.
 *
 * @ContentEntityType(
 *   id = "sfs_hostname",
 *   label = @Translation("SFS Hostname"),
 *   base_table = "sfs_hostname",
 *   entity_keys = {
 *     "id" = "id",
 *     "uuid" = "uuid",
 *     "label" = "hostname",
 *   },
 *   handlers = {
 *     "storage_schema" = "Drupal\sfs\SfsHostnameStorageSchema",
 *   },
 *   admin_permission = "administer sfs",
 * )
 */
class SfsHostname extends ContentEntityBase implements ContentEntityInterface {

  /**
   * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
   *
   * @return array|\Drupal\Core\Field\FieldDefinitionInterface[]|mixed
   */
  public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {

    $fields['id'] = BaseFieldDefinition::create('integer')
    ->setLabel(t('ID'))
      ->setReadOnly(TRUE);

    $fields['uuid'] = BaseFieldDefinition::create('uuid')
    ->setLabel(t('UUID'))
      ->setReadOnly(TRUE);

    $fields['hostname'] = BaseFieldDefinition::create('string')
      ->setLabel(t('Host name'));
	  
    $fields['uid'] = BaseFieldDefinition::create('integer')
    ->setLabel(t('User ID')); //index

    $fields['entity_id'] = BaseFieldDefinition::create('integer')
    ->setLabel(t('Entity ID'));

    $fields['entity_type'] = BaseFieldDefinition::create('string')
    ->setLabel(t('Entity type'));

    $fields['created'] = BaseFieldDefinition::create('created')
    ->setLabel(t('Creation date'));

    return $fields;
  }
}

Just in case in the future we might be interested in saving IP addresses for other entity types than nodes, I included the field "entity_type" to make that possible. I also included indexes to speed up the lookup of IP addresses. These are defined in the storage handler SfsHostnameStorageSchema to wich is reffered in the annotation of SfsHostname.

Writing the client for the Stop Forum Spam API

By Ronald van Belzen | May 10, 2018

Reading the description of the Stop Forum Spam api usage made me decide to use what is called "Multiple queries", which means checking the existence of a username, e-mail address and the IP address of a potential spammer in their database in a single call.

The number of response formats is large enough when it contains json. So I will use json and since I will use https over http when there is a choice, I picked https.

In the response I will concentrate on the "success", "appears" and "frequency" values and ignore the "confidence" score for now. It seems to me that just as many low as high confidence spammer are knocking at my door lately. So, it needs some more investigating before I can use that value in discriminating spammers from IP addresses formerly owned by spammers

The Client itself will be implemented as a service, allowing me to let Drupal do the heavy lifting with dependency injections. I will also add the modules very own cache bin with the name "sfs", because I plan to cache the api calls to www.stopforumspam.com. For this purpose I added the configuration parameter "sfs_cache_duration" to the module to allow administrators to set the cache time to their needs.

# sfs.services.yml
services:
  sfs.detect.spam:
    class: Drupal\sfs\SfsRequest
    arguments: ['@config.factory', '@current_user', '@logger.factory', '@http_client', '@database', '@cache.sfs']
  cache.sfs:
    class: Drupal\Core\Cache\CacheBackendInterface
    tags:
      - { name: cache.bin }
    factory: cache_factory:get
    arguments: [sfs]

The heart of the service will be the isSpammer() method that will the determination whether a user is a spammer or not.