Chapter 8. Dealing with Spam – Akismet to the Rescue

Before moving to another section of the social network, we still have something to add—comments. In this chapter, users will be able to comment on the entries made on the wall.

By the end of this chapter, you will know how to use the ZendService components to get extra functionality. We are going to use ZendServiceAskismet to protect our service from spammers, and we will check the contents of each comment on the API side to validate it against the Akismet servers.

API development

In this code, we will need to follow the same approach used in all the chapters related with the user wall. We need to create a new table to hold the data, then create the table gateway to be able to access the data, and finally modify the controller to add the new type of content to the create() method.

Requirements

We are not going to modify the way API works or the way developers interact with the API. This means that we will keep using the same HTTP methods as before and will modify the create() method to detect new content we want to add, and redirect the job to the proper specialized method.

Working with the database

Let's take a look at the structure of the new table we have to create to store the comments of the users.

This table is named user_comments, and will hold the following data:

  • Id
  • User_id
  • Type
  • Entity_id
  • Comment
  • Created at
  • Updated at

Also, in this table, as with all the ones we already created, we will add two columns to store the time where a record is created and updated.

The following is the statement to create the new table:

CREATE TABLE `user_comments` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `user_id` int(11) unsigned DEFAULT NULL,
  `type` tinyint(1) unsigned DEFAULT NULL,
  `entry_id` int(11) unsigned DEFAULT NULL,
  `comment` varchar(255) DEFAULT NULL,
  `created_at` timestamp NULL DEFAULT NULL,
  `updated_at` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_user_id` (`user_id`),
  KEY `idx_entry_id` (`entry_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

Module structure

Before we start describing the code, let's also look at the following folder structure and the new files we will create to build this functionality:

Module structure

Installing Akismet service

In order to use the Akismet service we should install the dependencies in our code. As ZF2 uses composer, this will be an easy task. Let's see the changes we have to do in the composer.json file to install Akismet. Alternatively, you can also use the code provided for this chapter that has Akismet already installed.

"repositories": [
    {
        "type": "composer",
        "url": "https://packages.zendframework.com/"
    }
],

Here, we are specifying the repository where the packages for Zend Framework can be found. After that we have to specify the package we want by adding the following line in the required block:

"zendframework/zendservice-akismet": "2.*@dev"

After that, we should run the composer to update the dependencies that are accomplished executing the following command:

php composer.phar update

This command should be executed in the root folder of the API project.

Modifying the local.php file

Now that we have the Akismet service installed, we need to store our API key somewhere to be able to send it when we request it to check if a comment is spam or not. Let's add a new section in the local.php file to store this, as follows:

'akismet' => array(
    'apiKey' => 'YOUR API KEY HERE',
    'url' => 'THE URL OF YOUR SERVICE HERE
)

Adding the UserCommentsTable.php file

As usual when we create a new table, we should also create the table gateway object that will allow us to work with the data stored in the database. This time, the file will be bigger than usual because we will add the validators to check if the comment is spam or not. As we already created a few classes similar to this one, we will just review the queries we provided and the configuration of the filters. For the full source code refer to the code of this chapter.

public function create($userId, $type, $entryId, $comment)
{
    return $this->insert(array(
        'user_id' => $userId,
        'type' => $type,
        'entry_id' => $entryId,
        'comment' => $comment,
        'created_at' => new Expression('NOW()'),
        'updated_at' => null
    ));
}

This is the method that will allow us to insert new comments in the table; entry_id refers to the ID of the content we are commenting and the type refers to the type of content.

public function getByTypeAndEntryId($type, $entryId)
{
    $select = $this->sql->select()->where(array(
        'type' => $type, 
        'entry_id' => $entryId)
    )->order('created_at ASC);
    return $this->selectWith($select);
}

This method will allow us to retrieve comments of a specific entry; we use the type of entry and entry_id to retrieve related comments, if any. Also, you can see that we order the rows by creation date with the new comments first.

Let's discuss now how to configure the validators for the comment. The three important checks we want to do are as follows:

  • The user making the comments exists in the database
  • The entry we are commenting on exists in the database
  • The comment is not considered spam

We will do the first two checks against the database using the RecordExists validator; for the last check we will use a custom validator, which we will create.

Let's see the following validator and filter configuration for each field. As the method is big, refer to the code of this chapter to see the entire code:

$inputFilter->add($factory->createInput(array(
    'name'     => 'user_id',
    'required' => true,
    'filters'  => array(
        array('name' => 'StripTags'),
        array('name' => 'StringTrim'),
        array('name' => 'Int'),
    ),
    'validators' => array(
        array('name' => 'NotEmpty'),
        array('name' => 'Digits'),
        array(
            'name' => 'ZendValidatorDbRecordExists',
            'options' => array(
                'table' => 'users',
                'field' => 'id',
                'adapter' => $this->adapter
            )
        )
    ),
)));

This block is the configuration for the user_id field as usual we remove the possible HTML tags, remove whitespaces at the beginning and end of the string, and we make sure it's a number. To validate the field we check that it is not empty, is a digit, and the record exists inside the users table.

$inputFilter->add($factory->createInput(array(
    'name'     => 'type',
    'required' => true,
    'filters'  => array(
        array('name' => 'StripTags'),
        array('name' => 'StringTrim')
    ),
    'validators' => array(
        array('name' => 'NotEmpty'),
        array('name' => 'Digits'),
    ),
)));

The type field is easier, as we just want to filter with the usual suspects, StripTags and StringTrim, and then we validate with NotEmpty, making sure we are dealing with a digit.

$inputFilter->add($factory->createInput(array(
    'name'     => 'entry_id',
    'required' => true,
    'filters'  => array(
        array('name' => 'StripTags'),
        array('name' => 'StringTrim')
    ),
    'validators' => array(
        array('name' => 'NotEmpty'),
        array('name' => 'Digits'),
        array(
            'name' => 'ZendValidatorDbRecordExists',
            'options' => array(
                'table' => $validatorTable,
                'field' => 'id',
                'adapter' => $this->adapter
            )
        )
    ),
)));

The configuration for entry_id is exactly the same as the one we created for the user with only one difference. The table we will use to check if the row exists is now a variable and will be configured based on the data we get from the client.

$inputFilter->add($factory->createInput(array(
    'name'     => 'comment',
    'required' => true,
    'validators' => array(
        array('name' => 'NotEmpty'),
        array(
            'name' => 'WallValidatorSpam',
            'options' => array(
                'apiKey' => $config['apiKey'],
                'url' => $config['url']
            )
        ),
    ),
)));

This is the last field we check. As you can see we do not filter the data, because it's an array created on the controller that will be validated directly with Spam.

Creating the Spam.php file

Now, let's create the validator that will use Akismet to check if a comment is considered spam or not. This time we will create a custom validator that will accept the configuration. With this knowledge, you will be able to create any validator that you may or may not need with the configuration.

namespace UsersValidator;

use ZendValidatorAbstractValidator;
use ZendServiceAkismetAkismet;

We start, as usual, by defining the namespace and the components we want to use in this class; after that we define the actual class.

class Spam extends AbstractValidator

Now we need to define some constants, one for each error condition we can find on the validator.

const INVALID = 'invalid';
const SPAM = 'isSpam';

The first two constants refer to error conditions while validating the data, and the last two are errors that can appear when you configure the validator.

protected $messageTemplates = array(
    self::INVALID => "Invalid input",
    self::SPAM => "The text seems to be spam"
);

As you see, we also need to define an error message attached to each error constant we defined before.

protected $options = array(
    'apiKey' => null,
    'url' => null
);

This variable holds the available configurations for the validator. In here, we have to define all the possible variables the validator needs to do the job, and will be populated by the parent constructor using the setter methods.

public function __construct($options = array())
{
    if (!is_array($options)) {
        $options = func_get_args();
        $temp['apiKey'] = array_shift($options);
        if (!empty($options)) {
            $temp['url'] = array_shift($options);
        }
        
        $options = $temp;
    }
    
    parent::__construct($options);
}

This is the constructor of the validator. We check how the configuration data has been passed to the validator and act accordingly. At the end, we call the parent constructor passing the configuration data as an array.

public function getApiKey()
{
    return $this->options['apiKey'];
}

public function setApiKey($apiKey)
{
    if (empty($apiKey)) {
        throw new Exception('API key cannot be empty'),
    }
    
    $this->options['apiKey'] = $apiKey;
    return $this;
}

This is the setter and getter for the apiKey configuration option in the setter; we check that the value is not empty, as the two configuration values are mandatory.

public function getUrl()
{
    return $this->options['url'];
}

public function setUrl($url)
{
    if (empty($url)) {
        throw new Exception('The url cannot be empty'),
    }
    
    $this->options['url'] = $url;
    return $this;
}

This is a copy of the getter and setter shown before, but in this case for the url option.

Now let's see the isValid() method that will use the configuration to connect to Akismet using the new Akismet service we added, and validate the comment.

public function isValid($value)
{
    if (!is_array($value)) {
        $this->error(self::INVALID);
        return false;
    }
    
    $this->setValue($value);
    $akismet = new Akismet($this->getApiKey(), $this->getUrl());
    if (!$akismet->verifyKey($this->getApiKey())) {
        throw new Exception('Invalid API key for Akismet'),
    }
    if ($akismet->isSpam($value)) {
        $this->error(self::SPAM);
        return false;
    } else {
        return true;
    }
}

This code will create a new instance of Akismet, then will verify the API key we provide, and finally check if the comment is considered spam or not.

Extending the IndexController.php file

In the controller, we have to modify two sections. The first is the one that processes an incoming request to save the comment and the second is the one that requests to get a user's wall. Let us see how to process the first request, validate that it is not spam, and store it.

We need to add a new check in the create() method to distinguish if the request is related with the creation of a comment.

At the end of the method we will add the following check:

if (array_key_exists('comment', $data) && !empty($data['comment'])) {
    $result = $this->createComment($data);
}

As you can see the check is similar to the ones we had before. Now we need to create the createComment() method that will take care of the actual processing of the comment.

The first block is as follows:

$userCommentsTable = $this->getUserCommentsTable();
$usersTable = $this->getUsersTable();
$user = $usersTable->getById($data['user_id']);

Here we are just getting two table gateways and retrieving the user who's commenting.

$data['comment'] = array(
    'user_ip' => $this->getRequest()->getServer('REMOTE_ADDR'),
    'user_agent' => $this->getRequest()->getServer(
        'HTTP_USER_AGENT'
    ),
    'comment_type' => 'comment',
    'comment_author' => sprintf(
        '%s %s', 
        $user->name, 
        $user->surname
    ),
    'comment_author_email' => $user->email,
    'comment_content' => $data['comment']
);

This is the array the Akismet service expects on their API level. Basically, we are providing them with the IP address of the user who's creating the comment, the user agent, a parameter to specify the type of content we are checking, the full name of the author of the comment, the e-mail, and the content itself.

switch ($data['type']) {
    case UsersModelUserstatusesTable::COMMENT_TYPE_ID:
        $validatorTable = 
            UsersModelUserstatusesTable::TABLE_NAME;
        break;
    case UsersModelUserImagesTable::COMMENT_TYPE_ID:
        $validatorTable = 
            UsersModelUserImagesTable::TABLE_NAME;
        break;
    case UsersModelUserLinksTable::COMMENT_TYPE_ID:
        $validatorTable = 
            UsersModelUserLinksTable::TABLE_NAME;
        break;
}

Then we need to configure the validator of the entity to check that it exists in the database if you remember we were using a variable instead of the table name, and here is where we determine the table name that should be used. On the client side, we will send a type number that identifies the type of content on which we are commenting. We will use this value to determine what table to pass in the getInputFilter() function, which in turn will be used to validate that the status being commented on exists in the database.

$config = $this->getServiceLocator()->get('Config'),
$filters = $userCommentsTable->getInputFilter(
    $validatorTable, 
    $config['akismet']
);
$filters->setData($data);

Here we are getting the configuration where we have stored the details we need for the Akismet service. We are also passing the table name for the entry validation and the Akismet configuration to the method that returns the input filter. After that we set the data in the filters to be able to perform the validation.

if ($filters->isValid()) {
    $data = $filters->getValues();
    
    $result = new JsonModel(array(
        'result' => $userCommentsTable->create(
            $data['user_id'], 
            $data['type'], 
            $data['entry_id'], 
            $data['comment']['comment_content']
        )
    ));
} else {
    $result = new JsonModel(array(
        'result' => false,
        'errors' => $filters->getMessages()
    ));
}

return $result;

This is the last block of the createComment() method and where we actually validate the data. In case of success, we retrieve the data from the filter, we then insert it in the database using the table gateway returning the results to the client. Otherwise, we return the error with an error message specifying what happened in the validation.

In order for this method to work, we also need to create a new property in this class, and a method called getUserCommentsTable() that will create and store a local copy of the table gateway.

protected $userCommentsTable;

protected function getUserCommentsTable()
{
    if (!$this->userCommentsTable) {
        $sm = $this->getServiceLocator();
        $this->userCommentsTable = $sm->get(
            'UsersModelUserCommentsTable'
        );
    }
    return $this->userCommentsTable;
}

We also need to modify the table gateways and to assign to each one a type to be able to check the content on which we are commenting, but that will be done later. Now, let's review the changes we should make in the get() method to retrieve the content of the wall, including the comments for each one.

$userCommentsTable = $this->getUserCommentsTable();

We need to add this line to the first block to get an instance of the comments table gateway.

$userStatuses = $userStatusesTable->getByUserId($userData->id)
    ->toArray();
$userImages = $userImagesTable->getByUserId($userData->id)
    ->toArray();
$userLinks = $userLinksTable->getByUserId($userData->id)
    ->toArray();

We already had these lines before, the only change we made was adding a call at the end to retrieve the data as arrays. This in turn helped us while processing the entries to get the comments.

$allEntries = array(
    UsersModelUserstatusesTable::COMMENT_TYPE_ID => 
        $userStatuses,
    UsersModelUserImagesTable::COMMENT_TYPE_ID => 
        $userImages,
    UsersModelUserLinksTable::COMMENT_TYPE_ID => 
        $userLinks
);

This array will hold the entries of each content type, and then we will use this array to merge the contents in one array that will be ordered.

$cachedUsers = array();
foreach ($allEntries as $type => &$entries) {
    foreach ($entries as &$entry) {
        $comments = $userCommentsTable->getByTypeAndEntryId(
            $type, 
            $entry['id']
        );
        
        if (count($comments) > 0) {
            foreach ($comments as $c) {
                if (array_key_exists($c->user_id, $cachedUsers)) {
                    $user = $cachedUsers[$c->user_id];
                } else {
                    $user = $usersTable->getById($c->user_id);
                    $cachedUsers[$c->user_id] = $user;
                }
                
                $entry['comments'][] = array(
                    'id' => $c->id,
                    'user' => $user,
                    'comment' => $c->comment
                );
            }
        }
    }
}

This block of code is responsible for getting comments of each content. As you can see we iterate through the array we just created, and then query the database. If we find comments, we iterate over them to get a copy of the data about the author of the comment to be able to display that data on the client. As we can have multiple comments from the same user, we create an array, of already retrieved users to avoid getting the same data from the database over and over again.

$wallData = $userData->getArrayCopy();
$wallData['feed'] = call_user_func_array(
    'array_merge', 
    $allEntries
);

These are the last two lines we need to modify. As we have the different entries under different keys in the $allEntries array we need to merge them to have one array with all the entries together.

Table gateways

As we mentioned before, in order to be able to detect the content type a user is commenting, we have to modify the table gateways to hold a unique type identifier on each one. Check the following table to see the relation between each type and the type assigned.

Type assigned

Content

1

Statuses (text)

2

Images

3

Links

Now we have to modify the files to store this data let's see the following change we have to do:

const COMMENT_TYPE_ID = 1;
const TABLE_NAME = 'user_statuses';

These are the new lines for the UserStatusesTable.php file; the lines will be the same in UserImagesTable.php and UserLinksTable.php, the only difference will be the number stored in the constant COMMENT_TYPE_ID and the name of the table stored in TABLE_NAME.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.145.15.1