Oct 28 2019
Oct 28

Recently one of our clients asked us to come up with a better language detection and redirection solution for their multilingual Drupal 8 site. Most out of the box solutions do not provide great user experience and IP based detection do not always work as expected. Browser based redirection also not an ideal option since at some point a visitor might want to manually choose wich language they want to see.

Having this issue in hand I started looking into possible solutions, I looked at number of multilingual Drupal and non-Drupal sites and couldn't find anything that would work for our client. I thought what if we ask a visitor what to do by showing them a box with browser detected langauge. This is just as Chrome's transaltion prompt that asks you if you'd like to translate the site. The prompt that is very simple and not as annyoing as some auto redirect solutions.

So this is what I came up with. A simple to use Drupal 8 module with few configurations needed. I decided to call it Language Suggestion.

The module supports auto redirects based on visitor selection and custom prompt messages per langauge. Here is the full list of configurable options:

  • Container CSS element. This is where you would need to specify main page container. You may need this when you have some additional message box at the top of the page, e.g. Cookie usage etc.. By default this is set to "body" HTML tag.
  • Always redirect. This option adds auto redirect to the website based on previous suggested language selection.
  • Language switch CSS element. This option overrides auto language redirect. For instance when use after auto redirect still wants to switch to another language they will be able to do so. Make sure to specify langauge switch element class name or ID.
  • Language suggestion delay. This option allows the language suggestion box to appear with delay. The value is in seconds.
  • Dismiss Delay. This option indicates how long language suggestion box should be hidden and when to reappear.
  • The broser language mapping is the mapping where you specify when to show language suggestion.
Language Suggestion Drupal 8 module

This is how language suggestion box appears on the site:

Langauge Suggestion prompt box

Configuration page is located at (Administration > Configuration > Regional and language > Language Suggestion) /admin/config/regional/language-suggestion

To test the module please make sure you have only one language in your browser settings. The language should be different from your current website language. And make sure that langauge is enabled in your Drupal 8 site.

I tried to keep visitors interaction annoyance level with the langauge suggestion prompt to its minimum. I hope this solution could be useful for you and your clients or give you some ideas.

Download Language Suggestion from Github

Sep 07 2017
Sep 07

Recently I had to generate term-specific aliases (aliases that are different from the default alias pattern set for Article entities). This is how to do it:

1. Enable the Pathauto module
2. Set the default URL alias pattern for your content type in order to fire the hook
3. Implement hook_pathauto_alias_alter() in your .module file.

Example module structure:

mymodule/
  - mymodule.info.yml
  - mymodule.module
  - src/
    - ArticlePathAlias.php

I like to keep .module clean and simple and because of that I store the main logic in src/ArticlePathAlias.php file.

The mymodule.info.yml this is just a regular .info file.

4. Add the following to your mymodule.module file:

use Drupal\mymodule\ArticlePathAlias;

/**
 * Implements hook_pathauto_alias_alter().
 */
function mymodule_pathauto_alias_alter(&$alias, array &$context) {
  if ($new_alias = (new ArticlePathAlias())->generate($context)) {
    $alias = $new_alias;
  }
}

5. Add the following to your src/ArticlePathAlias.php file:

<?php

namespace Drupal\mymodule;

use Drupal\Component\Utility\Html;
use Drupal\taxonomy\Entity\Term;

/**
 * Generate URL aliases for articles.
 */
class ArticlePathAlias {

  protected $terms = [
    'Term name 1' => 'custom/alias',
    'Term name 2' => 'custom2/alias',
    'Term name 3' => 'custom3/alias',
  ];
  protected $pattern = '/%term%/%year%/%monthnum%/%day%/%postname%';

  public function generate($context) {
    if ($context['bundle'] === 'article' && ($context['op'] == 'insert' || $context['op'] == 'update')) {
      return $this->assembleAlias($context['data']['node']);
    }
  }

  protected function assembleAlias($entity) {
    $date = new \DateTime(date('c', $entity->getCreatedTime()));
    $parameters = [
      '%year%'     => $date->format('Y'),
      '%monthnum%' => $date->format('m'),
      '%day%'      => $date->format('d'),
      '%postname%' => Html::cleanCssIdentifier($entity->getTitle()),
      '%term%'     => $this->findTermAlias($entity),
    ];
    if (!empty($parameters['%term%'])) {
      return str_replace(array_keys($parameters), array_values($parameters), $this->pattern);
    }
  }

  protected function findTermAlias($entity) {
    // Make sure to change `field_keywords` to the field you would like to check.
    if ($keywords = $entity->get('field_keywords')->getValue()) {
      foreach ($keywords as $data) {
        $term = Term::load($data['target_id']);
        $name = $term->getName();
        if (in_array($name, array_keys($this->terms))) {
          return $this->terms[$name];
        }
      }
    }
  }

}

The code above will generate /%term%/%year%/%monthnum%/%day%/%postname% alias or (/custom/alias/2017/07/21/test-title) depending on the term.

Make sure you change field_keywords to your own taxonomy term reference field. Also change $context['bundle'] === 'article' to entity type that will trigger custom alias.

Aug 26 2017
Aug 26

Sometimes you might want to display additional data in the autocomplete results, for instance add content language next to the title, or display entity type or any other related data. In this blog post I will demonstrate how to alter suggestions in autocomplete fields in Drupal 8. The project is available for download from github, see the link at the bottom of the page.

Link autocomplete results

Here is the module structure I will be using:

alter_entity_autocomplete/
  - alter_entity_autocomplete.info.yml
  - alter_entity_autocomplete.services.yml
  - src/
    - EntityAutocompleteMatcher.php
    - Controller/
      - EntityAutocompleteController.php
    - Routing/
      - AutocompleteRouteSubscriber.php

Contents of the alter_entity_autocomplete.info.yml file:

name: Alter Entity Autocomplete
description: The module alters entity autocomplete suggestion list.
type: module
core: 8.x

Contents of the alter_entity_autocomplete.services.yml file:

services:

  alter_entity_autocomplete.route_subscriber:
    class: Drupal\alter_entity_autocomplete\Routing\AutocompleteRouteSubscriber
    tags:
      - { name: event_subscriber }

  alter_entity_autocomplete.autocomplete_matcher:
    class: Drupal\alter_entity_autocomplete\EntityAutocompleteMatcher
    arguments: ['@plugin.manager.entity_reference_selection']

Contents of the src/EntityAutocompleteMatcher.php file. This is the file where you would change the output of the sugesstions/autocomplete results:

<?php

namespace Drupal\alter_entity_autocomplete;

use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\Tags;

class EntityAutocompleteMatcher extends \Drupal\Core\Entity\EntityAutocompleteMatcher {

  /**
   * Gets matched labels based on a given search string.
   */
  public function getMatches($target_type, $selection_handler, $selection_settings, $string = '') {

    $matches = [];

    $options = [
      'target_type'      => $target_type,
      'handler'          => $selection_handler,
      'handler_settings' => $selection_settings,
    ];

    $handler = $this->selectionManager->getInstance($options);

    if (isset($string)) {
      // Get an array of matching entities.
      $match_operator = !empty($selection_settings['match_operator']) ? $selection_settings['match_operator'] : 'CONTAINS';
      $entity_labels = $handler->getReferenceableEntities($string, $match_operator, 10);

      // Loop through the entities and convert them into autocomplete output.
      foreach ($entity_labels as $values) {
        foreach ($values as $entity_id => $label) {

          $entity = \Drupal::entityTypeManager()->getStorage($target_type)->load($entity_id);
          $entity = \Drupal::entityManager()->getTranslationFromContext($entity);

          $type = !empty($entity->type->entity) ? $entity->type->entity->label() : $entity->bundle();
          $status = '';
          if ($entity->getEntityType()->id() == 'node') {
            $status = ($entity->isPublished()) ? ", Published" : ", Unpublished";
          }

          $key = $label . ' (' . $entity_id . ')';
          // Strip things like starting/trailing white spaces, line breaks and tags.
          $key = preg_replace('/\s\s+/', ' ', str_replace("\n", '', trim(Html::decodeEntities(strip_tags($key)))));
          // Names containing commas or quotes must be wrapped in quotes.
          $key = Tags::encode($key);
          $label = $label . ' (' . $entity_id . ') [' . $type . $status . ']';
          $matches[] = ['value' => $key, 'label' => $label];
        }
      }
    }

    return $matches;
  }

}

Contents of the src/Controller/EntityAutocompleteController.php file:

<?php

namespace Drupal\alter_entity_autocomplete\Controller;

use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
use Drupal\alter_entity_autocomplete\EntityAutocompleteMatcher;
use Symfony\Component\DependencyInjection\ContainerInterface;

class EntityAutocompleteController extends \Drupal\system\Controller\EntityAutocompleteController {

  /**
   * The autocomplete matcher for entity references.
   */
  protected $matcher;

  /**
   * {@inheritdoc}
   */
  public function __construct(EntityAutocompleteMatcher $matcher, KeyValueStoreInterface $key_value) {
    $this->matcher = $matcher;
    $this->keyValue = $key_value;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('alter_entity_autocomplete.autocomplete_matcher'),
      $container->get('keyvalue')->get('entity_autocomplete')
    );
  }

}

Here is contents of the src/Routing/AutocompleteRouteSubscriber.php file:

<?php

namespace Drupal\alter_entity_autocomplete\Routing;

use Drupal\Core\Routing\RouteSubscriberBase;
use Symfony\Component\Routing\RouteCollection;

class AutocompleteRouteSubscriber extends RouteSubscriberBase {

  public function alterRoutes(RouteCollection $collection) {
    if ($route = $collection->get('system.entity_autocomplete')) {
      $route->setDefault('_controller', '\Drupal\alter_entity_autocomplete\Controller\EntityAutocompleteController::handleAutocomplete');
    }
  }

}

This module was developed with help of my co-workers and I hope you will find this useful.

Download from Github

Aug 25 2017
Aug 25

Sometimes you might want to display additional data in the autocomplete results, for instance add content language next to the title, or display entity type or any other related data. In this blog post I will demonstrate how to alter suggestions in autocomplete fields in Drupal 8. The project is available for download from github, see the link at the bottom of the page.

Link autocomplete results

Here is the module structure I will be using:

alter_entity_autocomplete/
  - alter_entity_autocomplete.info.yml
  - alter_entity_autocomplete.services.yml
  - src/
    - EntityAutocompleteMatcher.php
    - Controller/
      - EntityAutocompleteController.php
    - Routing/
      - AutocompleteRouteSubscriber.php

Contents of the alter_entity_autocomplete.info.yml file:

name: Alter Entity Autocomplete
description: The module alters entity autocomplete suggestion list.
type: module
core: 8.x

Contents of the alter_entity_autocomplete.services.yml file:

services:

  alter_entity_autocomplete.route_subscriber:
    class: Drupal\alter_entity_autocomplete\Routing\AutocompleteRouteSubscriber
    tags:
      - { name: event_subscriber }

  alter_entity_autocomplete.autocomplete_matcher:
    class: Drupal\alter_entity_autocomplete\EntityAutocompleteMatcher
    arguments: ['@plugin.manager.entity_reference_selection']

Contents of the src/EntityAutocompleteMatcher.php file. This is the file where you would change the output of the sugesstions/autocomplete results:

<?php

namespace Drupal\alter_entity_autocomplete;

use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\Tags;

class EntityAutocompleteMatcher extends \Drupal\Core\Entity\EntityAutocompleteMatcher {

  /**
   * Gets matched labels based on a given search string.
   */
  public function getMatches($target_type, $selection_handler, $selection_settings, $string = '') {

    $matches = [];

    $options = [
      'target_type'      => $target_type,
      'handler'          => $selection_handler,
      'handler_settings' => $selection_settings,
    ];

    $handler = $this->selectionManager->getInstance($options);

    if (isset($string)) {
      // Get an array of matching entities.
      $match_operator = !empty($selection_settings['match_operator']) ? $selection_settings['match_operator'] : 'CONTAINS';
      $entity_labels = $handler->getReferenceableEntities($string, $match_operator, 10);

      // Loop through the entities and convert them into autocomplete output.
      foreach ($entity_labels as $values) {
        foreach ($values as $entity_id => $label) {

          $entity = \Drupal::entityTypeManager()->getStorage($target_type)->load($entity_id);
          $entity = \Drupal::entityManager()->getTranslationFromContext($entity);

          $type = !empty($entity->type->entity) ? $entity->type->entity->label() : $entity->bundle();
          $status = '';
          if ($entity->getEntityType()->id() == 'node') {
            $status = ($entity->isPublished()) ? ", Published" : ", Unpublished";
          }

          $key = $label . ' (' . $entity_id . ')';
          // Strip things like starting/trailing white spaces, line breaks and tags.
          $key = preg_replace('/\s\s+/', ' ', str_replace("\n", '', trim(Html::decodeEntities(strip_tags($key)))));
          // Names containing commas or quotes must be wrapped in quotes.
          $key = Tags::encode($key);
          $label = $label . ' (' . $entity_id . ') [' . $type . $status . ']';
          $matches[] = ['value' => $key, 'label' => $label];
        }
      }
    }

    return $matches;
  }

}

Contents of the src/Controller/EntityAutocompleteController.php file:

<?php

namespace Drupal\alter_entity_autocomplete\Controller;

use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
use Drupal\alter_entity_autocomplete\EntityAutocompleteMatcher;
use Symfony\Component\DependencyInjection\ContainerInterface;

class EntityAutocompleteController extends \Drupal\system\Controller\EntityAutocompleteController {

  /**
   * The autocomplete matcher for entity references.
   */
  protected $matcher;

  /**
   * {@inheritdoc}
   */
  public function __construct(EntityAutocompleteMatcher $matcher, KeyValueStoreInterface $key_value) {
    $this->matcher = $matcher;
    $this->keyValue = $key_value;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('alter_entity_autocomplete.autocomplete_matcher'),
      $container->get('keyvalue')->get('entity_autocomplete')
    );
  }

}

Here is contents of the src/Routing/AutocompleteRouteSubscriber.php file:

<?php

namespace Drupal\alter_entity_autocomplete\Routing;

use Drupal\Core\Routing\RouteSubscriberBase;
use Symfony\Component\Routing\RouteCollection;

class AutocompleteRouteSubscriber extends RouteSubscriberBase {

  public function alterRoutes(RouteCollection $collection) {
    if ($route = $collection->get('system.entity_autocomplete')) {
      $route->setDefault('_controller', '\Drupal\alter_entity_autocomplete\Controller\EntityAutocompleteController::handleAutocomplete');
    }
  }

}

This module was developed with help of my co-workers and I hope you will find this useful.

Download from Github

Aug 18 2017
Aug 18

In this post I will show you a technique to fix HTML issues, import images or perform content operations during migrations.

We have to fix source content before most content migrations. This can be challenging if there are many entries in the source database. The powerful Drupal 8 Migration API provides elegant ways to solve this type of problem.

To solve HTML issues, I always create my own process plugin. Here is an example how you would call your own process plugin to fix HTML issues in the body field:

  'field_body/value':
    -
      plugin: fix_html_issues
      images_source: '/minnur/www/source-images'
      images_destination: 'public://body-images/'
      source: post_content
    -
      plugin: skip_on_empty
      method: row

As you can see, I am piling up several process plugins for field_body/value field migration. You may also pass custom parameters to your process plugin (in my example, params are: images_source and images_destination ). You may add any number of process plugins depending on your needs.

Now let's view the plugin code. Please note all of the process plugins are stored in the src/Plugin/migrate/process directory in your migration module.

The plugin imports images into Drupal as media entities and replaces <img> tags with Drupal entity embed tags <drupal-entity data-embed-button="embed_image"></drupal-entity>. Below is the source code of the plugin:

<?php

namespace Drupal\wp_migration\Plugin\migrate\process;

use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\file\FileInterface;
use Drupal\migrate\Row;
use Drupal\media_entity\Entity\Media;
use Drupal\Core\Database\Database;
use Drupal\Component\Utility\Unicode;

/**
 * @MigrateProcessPlugin(
 *   id = "fix_html_issues"
 * )
 */
class FixHTMLissues extends ProcessPluginBase {

  /**
   * {@inheritdoc}
   */
  public function transform($html, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {

    // Values for the following variables are specified in the YAML file above.
    $images_source = $this->configuration['images_source'];
    $destination = $this->configuration['images_destination'];

    preg_match_all('/<img[^>]+>/i', $html, $result);

    if (!empty($result[0])) {

      foreach ($result as $img_tags) {
        foreach ($img_tags as $img_tag) {

          preg_match_all('/(alt|title|src)=("[^"]*")/i', $img_tag, $tag_attributes);

          $filepath = str_replace('"', '', $tag_attributes[2][1]);

          if (!empty($tag_attributes[2][1])) {

            // Create file object from a locally copied file.
            $filename = basename($filepath);

            if (file_prepare_directory($destination, FILE_CREATE_DIRECTORY)) {

              if (filter_var($filepath, FILTER_VALIDATE_URL)) { 
                $file_contents = file_get_contents($filepath);
              }
              else {
                $file_contents = file_get_contents($images_source . $filepath);
              }
              $new_destination = $destination . '/' . $row->getSourceProperty('id') . '-' . $filename;

              if (!empty($file_contents)) {

                if ($file = file_save_data($file_contents, $new_destination, FILE_EXISTS_REPLACE)) {

                  // Create media entity using saved file.
                  $media = Media::create([
                    'bundle'      => 'image',
                    'uid'         => \Drupal::currentUser()->id(),
                    'langcode'    => \Drupal::languageManager()->getDefaultLanguage()->getId(),
                    'status'      => Media::PUBLISHED,
                    'field_image' => [
                      'target_id' => $file->id(),
                      'alt'       => !empty($tag_attributes[2][0]) ? Unicode::truncate(str_replace('"', '', $tag_attributes[2][0]), 512) : '',
                      'title'     => !empty($tag_attributes[2][0]) ? Unicode::truncate(str_replace('"', '', $tag_attributes[2][0]), 1024) : '',
                    ],
                  ]);

                  $media->save();
                  $uuid = $this->getMediaUuid($file);
                  $html = str_replace($img_tag, '<p><drupal-entity
                    data-embed-button="embed_image" 
                    data-entity-embed-display="entity_reference:media_thumbnail"
                    data-entity-embed-display-settings="{"image_style":"large","image_link":""}"
                    data-entity-type="media"
                    data-entity-uuid="' . $uuid . '"></drupal-entity>></p>', $html);
                }

              }

            }
          }
        }
      }
    }
    return $html;
  }

  /**
   * Get Media UUID by File ID.
   */
  protected function getMediaUuid(FileInterface $file) {
    $query = db_select('media__field_image', 'f', ['target' => 'default']);
    $query->innerJoin('media', 'm', 'm.mid = f.entity_id');
    $query->fields('m', ['uuid']);
    $query->condition('f.field_image_target_id', $file->id());
    $uuid = $query->execute()->fetchField();
    return $uuid;
  }

}

The process plugin code can get really nasty, and that's fine. Since this could be just a small portion of the overall migration, you don't want spend time to make it look nice and optimized. The best way to improve your code is to write more migrations and optimize it over time.

I hope this was helpful and I would love to hear about your techniques and solutions for content migration issues.

Aug 03 2017
Aug 03

In this post I will show a custom process plugin that I created to migrate taxonomy terms. The plugin handles the creation of new terms and prevents duplicates.

Below is a portion of the migration template. In the example, I am migrating new terms into keywords vocabulary via field_keywords field.

  field_keywords:
    -
      plugin: existing_term
      # Destination (Drupal) vocabulary name
      vocabulary: keywords
      # Source query should return term name
      source: term_name
    -
      plugin: skip_on_empty
      method: row

This is the source code for the process plugin.

<?php

namespace Drupal\my_module\Plugin\migrate\process;

use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\MigrateSkipProcessException;
use Drupal\migrate\Row;
use Drupal\taxonomy\Entity\Term;

/**
 * Check if term exists and create new if doesn't.
 *
 * @MigrateProcessPlugin(
 *   id = "existing_term"
 * )
 */
class ExistingTerm extends ProcessPluginBase {

  /**
   * {@inheritdoc}
   */
  public function transform($term_name, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
    $vocabulary = $this->configuration['vocabulary'];
    if (empty($term_name)) {
      throw new MigrateSkipProcessException();
    }
    if ($tid = $this->getTidByName($term_name, $vocabulary)) {
      $term = Term::load($tid);
    }
    else {
      $term = Term::create([
        'name' => $term_name, 
        'vid'  => $vocabulary,
      ])->save();
      if ($tid = $this->getTidByName($term_name, $vocabulary)) {
        $term = Term::load($tid);
      }
    }
    return [
      'target_id' => is_object($term) ? $term->id() : 0,
    ];
  }

  /**
   * Load term by name.
   */
  protected function getTidByName($name = NULL, $vocabulary = NULL) {
    $properties = [];
    if (!empty($name)) {
      $properties['name'] = $name;
    }
    if (!empty($vocabulary)) {
      $properties['vid'] = $vocabulary;
    }
    $terms = \Drupal::entityManager()->getStorage('taxonomy_term')->loadByProperties($properties);
    $term = reset($terms);
    return !empty($term) ? $term->id() : 0;
  }

}

The logic of the plugin is very simple. Please let me know in comments or questions. Also, please share any problems you've had during your content migrations and how you solved them.

Jul 28 2017
Jul 28

WordPress to Drupal 8

In this post I will show you how to migrate thumbnail content from Wordpress to Drupal 8. My goals are to help you better understand the content migration process, give you starting point for future migrations, and teach you how to write process plugins and migration sources. Taxonomy terms and users migration is more straightforward so I won't cover it here.

This migration example contains templates to migrate thumbnails content. For this post, I assume the image/thumbnail field is using the Media module field. I will be using the Migrate drush module to run migrations.

First, make sure to configure your connection in your settings.php file. Add the following with proper credentials:

$databases['migrate']['default'] = [
    'driver'   => 'mysql',
    'database' => 'wordpress_dbname',
    'username' => 'wordpress_dbuser',
    'password' => 'wordpress_dbpassowrd',
    'host'     => '127.0.0.1',
];

Here is the module structure I will be using:

wp_migration/
  - wp_migration.info.yml
  - wp_migration.module
  - migration_templates/
    - wp_content.yml
    - wp_thumbnail.yml
    - wp_media.yml
  - src/
    - Plugin/
      - migrate/
        - process/
          - AddUrlAliasPrefix.php
          - DateToTimestamp.php
        - source/
          - SqlBase.php
          - Content.php
          - Thumbnail.php

Contents of the wp_wordpress.info.yml file:

name: Wordpress Migration
type: module
description: Migrate Wordpress content into Drupal 8.
core: 8.x
dependencies:
  - migrate
  - migrate_drupal
  - migrate_drush

Contents of the migration_templates/wp_thumbnail.yml file:

id: wp_thumbnail
label: 'Thumbnails'
migration_tags:
  - Wordpress
source:
  plugin: wordpress_thumbnail
  # This is WP table prefix  (custom variable)
  # DB table example: [prefix]_posts
  table_prefix: wp
  constants:
    # This path should point ot WP uploads directory.
    source_base_path: '/path/to/source/wp/uploads'
    # This is directory name in Drupal where to store migrated files
    uri_file: 'public://wp-thumbnails'
process:
  filename: filename
  source_full_path:
    -
      plugin: concat
      delimiter: /
      source:
        - constants/source_base_path
        - filepath
    -
      plugin: urlencode
  uri_file:
    -
      plugin: concat
      delimiter: /
      source:
        - constants/uri_file
        - filename
    -
      plugin: urlencode
  uri:
    plugin: file_copy
    source:
      - '@source_full_path'
      - '@uri_file'
  status: 
    plugin: default_value
    default_value: 1
  changed: 
    plugin: date_to_timestamp
    source: post_date
  created: 
    plugin: date_to_timestamp
    source: post_date
  uid: 
    plugin: default_value
    default_value: 1
destination:
  plugin: 'entity:file'
migration_dependencies:
  required: {}
  optional: {}

Contents of the migration_templates/wp_media.yml file:

id: wp_media
label: 'Media'
migration_tags:
  - Wordpress
source:
  plugin: wordpress_thumbnail
  # This is WP table prefix  (custom variable)
  # DB table example: [prefix]_posts
  table_prefix: wp
  constants:
    bundle: image
process:
  bundle: 'constants/bundle'
  langcode:
    plugin: default_value
    default_value: en
  'field_image/target_id':
    -
      plugin: migration
      migration: wp_thumbnail
      source: post_id
    -
      plugin: skip_on_empty
      method: row
destination:
  plugin: 'entity:media'
migration_dependencies:
  required: {}
  optional: {}

Contents of the migration_templates/wp_content.yml file:

id: wp_content
label: 'Content'
migration_tags:
  - Wordpress
source:
  plugin: wordpress_content
  # Wordpress post type (custom variable)
  post_type: post
  # This is WP table prefix  (custom variable)
  # DB table example: [prefix]_posts
  table_prefix: wp
process:
  type:
    plugin: default_value
    default_value: article
  'path/pathauto':
    plugin: default_value
    default_value: 0
  'path/alias':
    # This will add the following to URL aliases in Drupal
    plugin: add_url_alias_prefix
    # url/alias/prefix/2017/07/21/[post-title]
    prefix: url/alias/prefix
    source: path_alias
  promote: 
    plugin: default_value
    default_value: 0
  sticky: 
    plugin: default_value
    default_value: 0
  langcode:
    plugin: default_value
    default_value: en
  status: 
    plugin: default_value
    default_value: 1
  title: post_title
  created: 
    plugin: date_to_timestamp
    source: post_date
  changed: 
    plugin: date_to_timestamp
    source: post_modified
  field_image:
    -
      plugin: migration
      migration: wp_media
      source: thumbnail
    -
      plugin: skip_on_empty
      method: row
  'body/summary': post_excerpt
  'body/format':
    plugin: default_value
    default_value: full_html
  'body/value': post_content
destination:
  plugin: 'entity:node'
migration_dependencies:
  required: {}
  optional: {}

Contents of the src/Plugin/migrate/process/AddUrlAliasPrefix.php file:

<?php

namespace Drupal\wp_migration\Plugin\migrate\process;

use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\Row;

/**
 * Add prefix to URL aliases.
 *
 * @MigrateProcessPlugin(
 *   id = "add_url_alias_prefix"
 * )
 */
class AddUrlAliasPrefix extends ProcessPluginBase {

  /**
   * {@inheritdoc}
   */
  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
    $prefix = !empty($this->configuration['prefix']) ? '/' . $this->configuration['prefix'] : '';
    return $prefix . $value;
  }

}

Contents of the src/Plugin/migrate/process/DateToTimestamp.php file:

<?php

namespace Drupal\wp_migration\Plugin\migrate\process;

use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\Row;

/**
 * Date to Timetamp conversion.
 *
 * @MigrateProcessPlugin(
 *   id = "date_to_timestamp"
 * )
 */
class DateToTimestamp extends ProcessPluginBase {

  /**
   * {@inheritdoc}
   */
  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
    return strtotime($value . ' UTC');
  }

}

I like to keep my module code clean and organized so I use base classes that I later extend in individual migration source files.

Here is contents of the src/Plugin/migrate/source/SqlBase.php file:

<?php

namespace Drupal\wp_migration\Plugin\migrate\source;

use Drupal\migrate_drupal\Plugin\migrate\source\DrupalSqlBase;
use Drupal\migrate\Row;

class SqlBase extends DrupalSqlBase {

  /**
   * Get database table prefix from the migration template.
   */
  protected function getPrefix() {
    return !empty($this->configuration['table_prefix']) ? $this->configuration['table_prefix'] : 'wp';
  }

  /**
   * Get Wordpress post type from the migration template.
   */
  protected function getPostType() {
    return !empty($this->configuration['post_type']) ? $this->configuration['post_type'] : 'post';
  }

  /**
   * Generate path alias via pattern specified in `permalink_structure`.
   */
  protected function generatePathAlias(Row $row) {
    $prefix = $this->getPrefix();
    $permalink_structure = $this->select($prefix . '_options', 'o', ['target' => 'migrate'])
      ->fields('o', ['option_value'])
      ->condition('o.option_name', 'permalink_structure')
      ->execute()
      ->fetchField();
    $date = new \DateTime($row->getSourceProperty('post_date'));
    $parameters = [
      '%year%'     => $date->format('Y'),
      '%monthnum%' => $date->format('m'),
      '%day%'      => $date->format('d'),
      '%postname%' => $row->getSourceProperty('post_name'),
    ];
    $url = str_replace(array_keys($parameters), array_values($parameters), $permalink_structure);
    return rtrim($url, '/');
  }

  /**
   * Get post thumbnail.
   */
  protected function getPostThumbnail(Row $row) {
    $prefix = $this->getPrefix();
    $query = $this->select($prefix . '_postmeta', 'pm', ['target' => 'migrate']);
    $query->innerJoin($prefix . '_postmeta', 'pm2', 'pm2.post_id = pm.meta_value');
    $query->fields('pm', ['post_id'])
      ->condition('pm.post_id', $row->getSourceProperty('id'))
      ->condition('pm.meta_key', '_thumbnail_id')
      ->condition('pm2.meta_key', '_wp_attached_file');
    return $query->execute()->fetchField();
  }

}

Contents of the src/Plugin/migrate/source/Thumbnail.php file:

<?php

namespace Drupal\wp_migration\Plugin\migrate\source;

use Drupal\migrate\Row;

/**
 * Extract content thumbnails.
 *
 * @MigrateSource(
 *   id = "wordpress_thumbnail"
 * )
 */
class Thumbnail extends SqlBase {

  /**
   * {@inheritdoc}
   */
  public function query() {
    $prefix = $this->getPrefix();
    $query = $this->select($prefix . '_postmeta', 'pm', ['target' => 'migrate']);
    $query->innerJoin($prefix . '_postmeta', 'pm2', 'pm2.post_id = pm.meta_value');
    $query->innerJoin($prefix . '_posts', 'p', 'p.id = pm.post_id');
    $query->fields('pm', ['post_id']);
    $query->fields('p', ['post_date']);
    $query->addField('pm2', 'post_id', 'file_id');
    $query->addField('pm2', 'meta_value', 'filepath');
    $query
      ->condition('pm.meta_key', '_thumbnail_id')
      ->condition('pm2.meta_key', '_wp_attached_file')
      ->condition('p.post_status', 'publish')
      ->condition('p.post_type', 'post');
    return $query;
  }

  /**
   * {@inheritdoc}
   */
  public function fields() {
    return [
      'post_id'   => $this->t('Post ID'),
      'post_date' => $this->t('Media Uploaded Date'),
      'file_id'   => $this->t('File ID'),
      'filepath'  => $this->t('File Path'),
      'filename'  => $this->t('File Name'),
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function getIds() {
    return [
      'post_id' => [
        'type'  => 'integer',
        'alias' => 'pm2',
      ],
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function prepareRow(Row $row) {
    $row->setSourceProperty('filename', basename($row->getSourceProperty('filepath')));
  }

}

Contents of the src/Plugin/migrate/source/Content.php file:

<?php

namespace Drupal\wp_migration\Plugin\migrate\source;

use Drupal\migrate\Row;

/**
 * Extract content from Wordpress site.
 *
 * @MigrateSource(
 *   id = "wordpress_content"
 * )
 */
class Content extends SqlBase {

  /**
   * {@inheritdoc}
   */
  public function query() {
    $prefix = $this->getPrefix();
    $query = $this->select($prefix . '_posts', 'p');
    $query
      ->fields('p', [
        'id',
        'post_date',
        'post_title',
        'post_content',
        'post_excerpt',
        'post_modified',
        'post_name'
      ]);
    $query->condition('p.post_status', 'publish');
    $query->condition('p.post_type', $this->getPostType());
    return $query;
  }

  /**
   * {@inheritdoc}
   */
  public function fields() {
    return [
      'id'            => $this->t('Post ID'),
      'post_title'    => $this->t('Title'),
      'thumbnail'     => $this->t('Post Thumbnail'),
      'post_excerpt'  => $this->t('Excerpt'),
      'post_content'  => $this->t('Content'),
      'post_date'     => $this->t('Created Date'),
      'post_modified' => $this->t('Modified Date'),
      'path_alias'    => $this->t('URL Alias'),
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function getIds() {
    return [
      'id' => [
        'type'  => 'integer',
        'alias' => 'p',
      ],
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function prepareRow(Row $row) {
    // This will generate path alias using WP alias settings.
    $row->setSourceProperty('path_alias', $this->generatePathAlias($row));
    // Get thumbnail ID and pass it to the wp_media migration plugin.
    $row->setSourceProperty('thumbnail', $this->getPostThumbnail($row));
  }

}

IMPORTANT: You must run migrations in their proper order. In this example you have to run wp_thumbnail first, wp_media second and wp_content last.

Comments Not Loading?

Due to some temporarily SSL cert issue please refresh the page using this link in order to be able to leave comments.

Jul 27 2017
Jul 27

WordPress to Drupal 8

In this post I will show you how to migrate thumbnail content from Wordpress to Drupal 8. My goals are to help you better understand the content migration process, give you starting point for future migrations, and teach you how to write process plugins and migration sources. Taxonomy terms and users migration is more straightforward so I won't cover it here.

This migration example contains templates to migrate thumbnails content. For this post, I assume the image/thumbnail field is using the Media module field. I will be using the Migrate drush module to run migrations.

First, make sure to configure your connection in your settings.php file. Add the following with proper credentials:

$databases['migrate']['default'] = [
    'driver'   => 'mysql',
    'database' => 'wordpress_dbname',
    'username' => 'wordpress_dbuser',
    'password' => 'wordpress_dbpassowrd',
    'host'     => '127.0.0.1',
];

Here is the module structure I will be using:

wp_migration/
  - wp_migration.info.yml
  - wp_migration.module
  - migration_templates/
    - wp_content.yml
    - wp_thumbnail.yml
    - wp_media.yml
  - src/
    - Plugin/
      - migrate/
        - process/
          - AddUrlAliasPrefix.php
          - DateToTimestamp.php
        - source/
          - SqlBase.php
          - Content.php
          - Thumbnail.php

Contents of the wp_wordpress.info.yml file:

name: Wordpress Migration
type: module
description: Migrate Wordpress content into Drupal 8.
core: 8.x
dependencies:
  - migrate
  - migrate_drupal
  - migrate_drush

Contents of the migration_templates/wp_thumbnail.yml file:

id: wp_thumbnail
label: 'Thumbnails'
migration_tags:
  - Wordpress
source:
  plugin: wordpress_thumbnail
  # This is WP table prefix  (custom variable)
  # DB table example: [prefix]_posts
  table_prefix: wp
  constants:
    # This path should point ot WP uploads directory.
    source_base_path: '/path/to/source/wp/uploads'
    # This is directory name in Drupal where to store migrated files
    uri_file: 'public://wp-thumbnails'
process:
  filename: filename
  source_full_path:
    -
      plugin: concat
      delimiter: /
      source:
        - constants/source_base_path
        - filepath
    -
      plugin: urlencode
  uri_file:
    -
      plugin: concat
      delimiter: /
      source:
        - constants/uri_file
        - filename
    -
      plugin: urlencode
  uri:
    plugin: file_copy
    source:
      - '@source_full_path'
      - '@uri_file'
  status: 
    plugin: default_value
    default_value: 1
  changed: 
    plugin: date_to_timestamp
    source: post_date
  created: 
    plugin: date_to_timestamp
    source: post_date
  uid: 
    plugin: default_value
    default_value: 1
destination:
  plugin: 'entity:file'
migration_dependencies:
  required: {}
  optional: {}

Contents of the migration_templates/wp_media.yml file:

id: wp_media
label: 'Media'
migration_tags:
  - Wordpress
source:
  plugin: wordpress_thumbnail
  # This is WP table prefix  (custom variable)
  # DB table example: [prefix]_posts
  table_prefix: wp
  constants:
    bundle: image
process:
  bundle: 'constants/bundle'
  langcode:
    plugin: default_value
    default_value: en
  'field_image/target_id':
    -
      plugin: migration
      migration: wp_thumbnail
      source: post_id
    -
      plugin: skip_on_empty
      method: row
destination:
  plugin: 'entity:media'
migration_dependencies:
  required: {}
  optional: {}

Contents of the migration_templates/wp_content.yml file:

id: wp_content
label: 'Content'
migration_tags:
  - Wordpress
source:
  plugin: wordpress_content
  # Wordpress post type (custom variable)
  post_type: post
  # This is WP table prefix  (custom variable)
  # DB table example: [prefix]_posts
  table_prefix: wp
process:
  type:
    plugin: default_value
    default_value: article
  'path/pathauto':
    plugin: default_value
    default_value: 0
  'path/alias':
    # This will add the following to URL aliases in Drupal
    plugin: add_url_alias_prefix
    # url/alias/prefix/2017/07/21/[post-title]
    prefix: url/alias/prefix
    source: path_alias
  promote: 
    plugin: default_value
    default_value: 0
  sticky: 
    plugin: default_value
    default_value: 0
  langcode:
    plugin: default_value
    default_value: en
  status: 
    plugin: default_value
    default_value: 1
  title: post_title
  created: 
    plugin: date_to_timestamp
    source: post_date
  changed: 
    plugin: date_to_timestamp
    source: post_modified
  field_image:
    -
      plugin: migration
      migration: wp_media
      source: thumbnail
    -
      plugin: skip_on_empty
      method: row
  'body/summary': post_excerpt
  'body/format':
    plugin: default_value
    default_value: full_html
  'body/value': post_content
destination:
  plugin: 'entity:node'
migration_dependencies:
  required: {}
  optional: {}

Contents of the src/Plugin/migrate/process/AddUrlAliasPrefix.php file:

<?php

namespace Drupal\wp_migration\Plugin\migrate\process;

use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\Row;

/**
 * Add prefix to URL aliases.
 *
 * @MigrateProcessPlugin(
 *   id = "add_url_alias_prefix"
 * )
 */
class AddUrlAliasPrefix extends ProcessPluginBase {

  /**
   * {@inheritdoc}
   */
  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
    $prefix = !empty($this->configuration['prefix']) ? '/' . $this->configuration['prefix'] : '';
    return $prefix . $value;
  }

}

Contents of the src/Plugin/migrate/process/DateToTimestamp.php file:

<?php

namespace Drupal\wp_migration\Plugin\migrate\process;

use Drupal\migrate\ProcessPluginBase;
use Drupal\migrate\MigrateExecutableInterface;
use Drupal\migrate\Row;

/**
 * Date to Timetamp conversion.
 *
 * @MigrateProcessPlugin(
 *   id = "date_to_timestamp"
 * )
 */
class DateToTimestamp extends ProcessPluginBase {

  /**
   * {@inheritdoc}
   */
  public function transform($value, MigrateExecutableInterface $migrate_executable, Row $row, $destination_property) {
    return strtotime($value . ' UTC');
  }

}

I like to keep my module code clean and organized so I use base classes that I later extend in individual migration source files.

Here is contents of the src/Plugin/migrate/source/SqlBase.php file:

<?php

namespace Drupal\wp_migration\Plugin\migrate\source;

use Drupal\migrate_drupal\Plugin\migrate\source\DrupalSqlBase;
use Drupal\migrate\Row;

class SqlBase extends DrupalSqlBase {

  /**
   * Get database table prefix from the migration template.
   */
  protected function getPrefix() {
    return !empty($this->configuration['table_prefix']) ? $this->configuration['table_prefix'] : 'wp';
  }

  /**
   * Get Wordpress post type from the migration template.
   */
  protected function getPostType() {
    return !empty($this->configuration['post_type']) ? $this->configuration['post_type'] : 'post';
  }

  /**
   * Generate path alias via pattern specified in `permalink_structure`.
   */
  protected function generatePathAlias(Row $row) {
    $prefix = $this->getPrefix();
    $permalink_structure = $this->select($prefix . '_options', 'o', ['target' => 'migrate'])
      ->fields('o', ['option_value'])
      ->condition('o.option_name', 'permalink_structure')
      ->execute()
      ->fetchField();
    $date = new \DateTime($row->getSourceProperty('post_date'));
    $parameters = [
      '%year%'     => $date->format('Y'),
      '%monthnum%' => $date->format('m'),
      '%day%'      => $date->format('d'),
      '%postname%' => $row->getSourceProperty('post_name'),
    ];
    $url = str_replace(array_keys($parameters), array_values($parameters), $permalink_structure);
    return rtrim($url, '/');
  }

  /**
   * Get post thumbnail.
   */
  protected function getPostThumbnail(Row $row) {
    $prefix = $this->getPrefix();
    $query = $this->select($prefix . '_postmeta', 'pm', ['target' => 'migrate']);
    $query->innerJoin($prefix . '_postmeta', 'pm2', 'pm2.post_id = pm.meta_value');
    $query->fields('pm', ['post_id'])
      ->condition('pm.post_id', $row->getSourceProperty('id'))
      ->condition('pm.meta_key', '_thumbnail_id')
      ->condition('pm2.meta_key', '_wp_attached_file');
    return $query->execute()->fetchField();
  }

}

Contents of the src/Plugin/migrate/source/Thumbnail.php file:

<?php

namespace Drupal\wp_migration\Plugin\migrate\source;

use Drupal\migrate\Row;

/**
 * Extract content thumbnails.
 *
 * @MigrateSource(
 *   id = "wordpress_thumbnail"
 * )
 */
class Thumbnail extends SqlBase {

  /**
   * {@inheritdoc}
   */
  public function query() {
    $prefix = $this->getPrefix();
    $query = $this->select($prefix . '_postmeta', 'pm', ['target' => 'migrate']);
    $query->innerJoin($prefix . '_postmeta', 'pm2', 'pm2.post_id = pm.meta_value');
    $query->innerJoin($prefix . '_posts', 'p', 'p.id = pm.post_id');
    $query->fields('pm', ['post_id']);
    $query->fields('p', ['post_date']);
    $query->addField('pm2', 'post_id', 'file_id');
    $query->addField('pm2', 'meta_value', 'filepath');
    $query
      ->condition('pm.meta_key', '_thumbnail_id')
      ->condition('pm2.meta_key', '_wp_attached_file')
      ->condition('p.post_status', 'publish')
      ->condition('p.post_type', 'post');
    return $query;
  }

  /**
   * {@inheritdoc}
   */
  public function fields() {
    return [
      'post_id'   => $this->t('Post ID'),
      'post_date' => $this->t('Media Uploaded Date'),
      'file_id'   => $this->t('File ID'),
      'filepath'  => $this->t('File Path'),
      'filename'  => $this->t('File Name'),
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function getIds() {
    return [
      'post_id' => [
        'type'  => 'integer',
        'alias' => 'pm2',
      ],
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function prepareRow(Row $row) {
    $row->setSourceProperty('filename', basename($row->getSourceProperty('filepath')));
  }

}

Contents of the src/Plugin/migrate/source/Content.php file:

<?php

namespace Drupal\wp_migration\Plugin\migrate\source;

use Drupal\migrate\Row;

/**
 * Extract content from Wordpress site.
 *
 * @MigrateSource(
 *   id = "wordpress_content"
 * )
 */
class Content extends SqlBase {

  /**
   * {@inheritdoc}
   */
  public function query() {
    $prefix = $this->getPrefix();
    $query = $this->select($prefix . '_posts', 'p');
    $query
      ->fields('p', [
        'id',
        'post_date',
        'post_title',
        'post_content',
        'post_excerpt',
        'post_modified',
        'post_name'
      ]);
    $query->condition('p.post_status', 'publish');
    $query->condition('p.post_type', $this->getPostType());
    return $query;
  }

  /**
   * {@inheritdoc}
   */
  public function fields() {
    return [
      'id'            => $this->t('Post ID'),
      'post_title'    => $this->t('Title'),
      'thumbnail'     => $this->t('Post Thumbnail'),
      'post_excerpt'  => $this->t('Excerpt'),
      'post_content'  => $this->t('Content'),
      'post_date'     => $this->t('Created Date'),
      'post_modified' => $this->t('Modified Date'),
      'path_alias'    => $this->t('URL Alias'),
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function getIds() {
    return [
      'id' => [
        'type'  => 'integer',
        'alias' => 'p',
      ],
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function prepareRow(Row $row) {
    // This will generate path alias using WP alias settings.
    $row->setSourceProperty('path_alias', $this->generatePathAlias($row));
    // Get thumbnail ID and pass it to the wp_media migration plugin.
    $row->setSourceProperty('thumbnail', $this->getPostThumbnail($row));
  }

}

IMPORTANT: You must run migrations in their proper order. In this example you have to run wp_thumbnail first, wp_media second and wp_content last.

Comments Not Loading?

Due to some temporarily SSL cert issue please refresh the page using this link in order to be able to leave comments.

Jul 18 2017
Jul 18

AppNexus + Drupal 8

One of our clients requested to integrate AppNexus ads with their Drupal 8 site. The requirements were very simple:

  • Ability to display ads as blocks.
  • Ad blocks should be configurable: tagId, Sizes. And pass those variables via apntag.defineTag().
  • Ability to pass global page options via apntag.setPageOpts().

Since there wasn't any module that would support this I decided to create a module that will be very easy to use and at the same time will be extendable and useful in other projects.

The module comes with a simple PHP class that helps to generate pageOpts from hook_page_attachments(). You may create your own class using that class as an example.

use Drupal\appnexus\PageOpts;

/**
 * Implements hook_page_attachments().
 */
function mymodule_page_attachments(array &$page) {
  $page_opts = (new PageOpts())
    ->setKeywordQuotes()
    ->setMember('YOUR_MEMBER_ID')
    ->setKeyword('var1', 'value')
    ->setKeyword('var2', ['val1', 'val2'])
    ->setKeyword('var3', 1234);
  if ($opts = $page_opts->build()) {
    $page['#attached']['drupalSettings']['appnexus']['opts'] = json_encode($opts);
  }
}

Please make sure you specify Member ID in order to properly pass all the parameters.

Download AppNexus

Jul 15 2017
Jul 15

Amp and Drupal 8

Adding AMP (Accelerated Mobile Pages) support could be very tricky and complex depending on your project needs. In this blog post I won't be using AMP module. Instead I created something more simpler and easier to use for AMP integration. The Simple AMP module is still using Lullabot's AMP PHP library. The module is a starter module and most likely won't be very useful out of the box, but it can get you going very fast. You may need to extend it and this shouldn't as hard as in other modules.

Download Simple AMP

Installation

First thing you need to do is install composer require lullabot/amp, the module won't work without it.

How to Use

  • Create new Display Mode for your entity that will support AMP. New View modes can be created via /admin/structure/display-modes/view.
  • Enable newly created View mode on entity Manage Display page. Example path: /admin/structure/types/manage/article/display
  • Open Entity Types page and enable entity type that supports AMP and choose Display Mode for it. /admin/config/services/simple-amp.
  • Open Components page and and enable default components. /admin/config/services/simple-amp/components.
  • Enable Access AMP pages permission.
  • Hit Save configuration and you're all set.

URL alias

If you would like to have URL alias in the path /node-alias/amp instead of /node/[nid]/amp please install Sub-pathauto (Sub-path URL Aliases)

Theming

Easy to extend and alter AMP templates per content type: amp--[ENTITY_TYPE].html.twig or amp--[ENTITY_TYPE]--[BUNDLE].html.twig

Extend Simple AMP module

The module is extendable, there are two components: AmpComponent and AmpMetadata. I built them using Drupal 8 plugin system which will let anyone extend it to match your project needs.

AmpComponent

  • All plugins stored in src/Plugin/AmpComponent/*. currently the module doesn't support all available AMP components, but can be easily extended from your own module.

Here is example:

The key variables here are:

  • name - plugin name
  • description - plugin description.
  • regexp - array of regular expressions to match in HTML body.
<?php

namespace Drupal\simple_amp\Plugin\AmpComponent;

use Drupal\simple_amp\AmpComponentBase;

/**
 * Youtube AMP component.
 *
 * @AmpComponent(
 *   id = "amp-youtube",
 *   name = @Translation("YouTube"),
 *   description = @Translation("YouTube Component"),
 *   regexp = {
 *     "/<amp\-youtube.*><\/amp\-youtube>/isU",
 *   }
 * )
 */
class Youtube extends AmpComponentBase {

  /**
   * {@inheritdoc}
   */
  public function getElement() {
    return '<script async custom-element="amp-youtube" src="https://cdn.ampproject.org/v0/amp-youtube-0.1.js"></script>';
  }

}

AmpMetadata

  • All plugins stored in src/Plugin/AmpMetadata/*. provide Metadata for specific entity.

Here is example:

The key variables here are:

  • entity_types - array of entity type names. This will tell the module to generate AMP Metadata for entity types specified in this variable.
<?php

namespace Drupal\simple_amp\Plugin\AmpMetadata;

use Drupal\simple_amp\AmpMetadataBase;
use Drupal\simple_amp\Metadata\Metadata;
use Drupal\simple_amp\Metadata\Author;
use Drupal\simple_amp\Metadata\Publisher;
use Drupal\simple_amp\Metadata\Image;

/**
 * Example AMP metadata component.
 *
 * @AmpMetadata(
 *   id = "default",
 *   entity_types = {
 *     "example_article"
 *   }
 * )
 */
class ExampleEntity extends AmpMetadataBase {

  /**
   * {@inheritdoc}
   */
  public function getMetadata($entity) {
    $metadata = new Metadata();
    $author = (new Author())
      ->setName('Test Author');
    $logo = (new Image())
      ->setUrl('http://url-to-image')
      ->setWidth(400)
      ->setHeight(300);
    $publisher = (new Publisher())
      ->setName('MyWebsite.com')
      ->setLogo($logo);
    $image = (new Image())
      ->setUrl('http://url-to-image')
      ->setWidth(400)
      ->setHeight(300);
    $metadata
      ->setDatePublished($entity->getCreatedTime())
      ->setDateModified($entity->getChangedTime())
      ->setDescription('test')
      ->setAuthor($author)
      ->setPublisher($publisher)
      ->setImage($image);
    return $metadata->build();
  }

}

Support

Feel free to create pull requests, help is always appreciated.

Comments Not Loading?

Due to some temporarily SSL cert issue please refresh the page using this link in order to be able to leave comments.

Jul 13 2017
Jul 13

Adding AMP support could be very tricky and complex depending on your project needs. In this blog post I won't be using AMP module. Instead I created something more simple and easier to use for simple AMP integration. The Simple AMP module is still using Lullabot's AMP PHP library. The module is a starter module and most likely won't be very useful out of the box, but it can get you going very fast. It is available for download here https://github.com/chapter-three/simple_amp.

First thing you need to do is to install composer require lullabot/amp, the module won't work without this library.

Once you install the module modify few lines of code in the module:

  • Modify Drupal\simple_amp\AmpBase::getMetadata() to reflect data that you would like to show in AMP metadata.
  • Modify template in simple_amp/templates/amp.html.twig to match your design needs.
  • You may also have custom template per content type: amp--node.html.twig or amp--node--article.html.twig

By default Simple AMP module includes only basic AMP JS files and in case if you need to add other AMP JS libraries you would have to add some code.

In order to add additional AMP JS files you will have to modify Drupal\simple_amp\AmpBase::detect() method. You would need to write some regular expressions to detect ads, youtube videos, vimeo, instagram or any other libraries. Once detected you would add that library to protected $scripts = [] array in the AmpBase class.

Example:

protected function detect() {
    $youtube = [
      '/youtube\.com\/watch\?v=([a-z0-9\-_]+)/i',
      '/youtube\.com\/embed\/([a-z0-9\-_]+)/i',
      '/youtu.be\/([a-z0-9\-_]+)/i',
      '/youtube\.com\/v\/([a-z0-9\-_]+)/i',
    ];
    foreach ($youtube as $regexp) {
      if (preg_match($regexp, $this->html)) {
        $this->scripts[] = '<script async custom-element="amp-youtube" src="https://cdn.ampproject.org/v0/amp-youtube-0.1.js"></script>';
      }
    }
  }

Roadmap

  • Improve UI
  • Eliminate any need for code modifications.

Feel free to create pull requests, help is always appreciated.

Oct 11 2016
Oct 11

How to use Drush CommandsIn this blog post I will provide an example of a method I use to deploy changes on the projects that I work on my own time. This method is very useful when there is a need to make a lot of manual changes to the site when there is a new release. You could use this method to automate deployment process of your Drupal sites. In fact this method can be used for any other non-Drupal sites too.

For this blog post I am using Drush commands.

Lets start form a very simple example. I will use Acquia's directory structure to descibe where I am storing release scripts.

acquia-utils/
docroot/
hooks/
...
releases/
  - code/
    - release-2/
      - permissions.php
      - users.php
  - release-1.sh
  - release-2.sh
  - release-3.sh

As you can see releases/ directory is on the same level a docroot/. That's where the scripts would locate.

release-1.sh - A very simple release script that runs database updates and reverts features.

#!/bin/bash

# Database updates.
drush updb -y

# Revert features.
drush fr my_feature1 my_feature2 my_feature3 -y


release-2.sh - Release script with examples of how to run SQL queries and run PHP code.

#!/bin/bash

# Change weight of the module.
drush sql-query 'UPDATE system SET weight = 100 WHERE name = "my_custom_module"' -y

# Execute PHP code

# Make changes to permissions
drush php-script 'permissions.php' --script-path="../releases/code/release-2/" --user=[Drupal Admin Username]

# Make changes to users.
drush php-script 'users.php' --script-path="../releases/code/release-2/" --user=[Drupal Admin Username]


release-3.sh - Release script with additional examples.

#!/bin/bash

# Set variables
drush vset cache_lifetime 86400 -y
drush vset page_cache_maximum_age 86400 -y
drush vset error_level 0 -y
drush vset --always-set cache 1

# Execute inline PHP code

# Set permissions
drush php-eval "user_role_grant_permissions(DRUPAL_AUTHENTICATED_RID, array('my_module_name' => 'permission name'));"

# Set variable
drush php-eval "variable_set('my_variable_name', array('key' => 'value'));" -y

# Clear Drupal cache
drush cc all


And finally, you would need to ssh to the server and run each release script.

What else you could do:

  • You could add backup logic so every time you run the script you would create database and files backups.
  • Make clean Drupal install locally, download database from production and restore it locally with Drush.
  • Run content migrations.

Use your imagination and please share with us the methods you use to improve your deployment process.

Sep 06 2016
Sep 06

Custom Drush Commands for Drupal 8 and Drupal 7It is very easy to create your own custom Drush commands. In this blog post I will show two examples for Drupal 7 and Drupal 8.

Creating custom Drush commands could be very useful when you need to import data into Drupal or export data from Drupal. You would have to create a separate bash script where you would execute your custom command and add the bash script to your crontab.

Drupal 8 example

Drupal 8 module structure:

drush_example/
  - drush_example.info.yml
  - drush_example.drush.inc

drush_example.info.yml:

name: Drush Example command
description: Drupal 8 custom drush command example
type: module
core: 8.x

drush_example.drush.inc

/**
 * Implements hook_drush_command().
 */
function drush_example_drush_command() {

  $commands['my-example-command'] = [
    'description' => 'This is my example command.',
    'aliases' => ['mec'],
    'arguments' => [
       'arg1' => 'My custom argument 1.',
       'arg2' => 'My custom argument 2.',
     ],
     'options' => [
       'opt1' => 'My custom option.',
     ],
     'examples' => [
       'drush mec' => 'Print my example command.',
       'drush mec myargument' => 'Print my example command with an argument "myargument".',
       'drush mec myargument --opt1=myoption' => 'Print my example command with an argument "myargument" and an option "myoption".',
     ],
  ];

  return $commands;
}

/**
 * Drush command logic.
 * drush_[MODULE_NAME]_[COMMAND_NAME]().
 */
function drush_drush_example_my_example_command($arg1 = 'N/A', $arg2 = 'N/A') {
  $opt1 = drush_get_option('opt1', 'N/A');
  $tokens = ['@arg1' => $arg1, '@opt1' => $opt1];
  drush_print(dt('My custom command. Argument: @arg1 Option: @opt1', $tokens));
}

Drupal 7 example

I will use the following structure for the D7 example module:

drush_example/
  - drush_example.info
  - drush_example.module (empty .module file)
  - drush_example.drush.inc

drush_example.info:

name = Drush Example command 
description = Drupal 7 custom drush command example
core = 7.x

drush_example.drush.inc

/**
 * Implements hook_drush_command().
 */
function drush_example_drush_command() {

  $commands['my-example-command'] = array(
    'description' => 'This is my example command.',
    'aliases' => array('mec'),
    'arguments' => array(
       'arg1' => 'My custom argument 1.',
       'arg2' => 'My custom argument 2.',
     ),
     'options' => array(
       'opt1' => 'My custom option.',
     ),
     'examples' => array(
       'drush mec' => 'Print my example command.',
       'drush mec myargument' => 'Print my example command with an argument "myargument".',
       'drush mec myargument --opt1=myoption' => 'Print my example command with an argument "myargument" and an option "myoption".',
     ),
  );

  return $commands;
}

/**
 * Drush command logic.
 * drush_[COMMAND_NAME]().
 */
function drush_my_example_command($arg1 = 'N/A', $arg2 = 'N/A') {
  $opt1 = drush_get_option('opt1', 'N/A');
  $tokens = array('@arg1' => $arg1, '@opt1' => $opt1);
  drush_print(dt('My custom command. Argument: @arg1 Option: @opt1', $tokens));
}

May 30 2016
May 30

How to Improve Performance with HTTP cache headersIn this blog post I will show you a simple technique to improve your web application performance by modifying headers. Please keep in mind, if you're using HTTP reverse proxy caching applications such as Varnish you might harm your application performance or your settings could be ignored.

This technique could help improve page loads for authenticated users where reverse proxy caching disabled.

Important: Drupal already already provides all the required headers when Performance settings properly configured. This is information is generic and could be very helpful for decoupled projects or any other frameworks.

There are two primary cache headers, Cache-Control and Expires. You can set caching either time-based, content-based or on Expire date.

Cache-Control

The Cache-Control general-header field is used to specify directives that MUST be obeyed by all caching mechanisms along the request/response chain.

Settings

Example header:
Cache-Control: max-age=900, public

public - Indicates that the response MAY be cached by any cache, even if it would normally be non-cacheable or cacheable only within a non- shared cache.

private - Indicates that all or part of the response message is intended for a single user and MUST NOT be cached by a shared cache. This allows an origin server to state that the specified parts of the response are intended for only one user and are not a valid response for requests by other users. This parameter doesn't provide the same level of privacy as SSL does.

max-age - Indicates that the client is willing to accept a response whose age is no greater than the specified time in seconds. Unless max- stale directive is also included, the client is not willing to accept a stale response. (The value is in seconds)

See all available options RFC 2616

Expires

Note: If both Expires and max-age are set max-age will take precedence.

This header parameter tells the browser when to next retrieve the resource from the network.

Example:

Cache-Control:public
Expires: Mon, 25 May 2016 11:31:12 GMT

Time-based

Last-Modified - The Last-Modified entity-header field indicates the date and time at which the origin server believes the variant was last modified.

Example:
Last-Modified: Wed, 25 May 2016 11:45:26 GMT

If-Modified-Since - The If-Modified-Since request-header field is used with a method to make it conditional: if the requested variant has not been modified since the time specified in this field, an entity will not be returned from the server; instead, a 304 (not modified) response will be returned without any message-body.

Example:
If-Modified-Since: Wed, 25 May 2016 11:45:26 GMT

Content-based

ETag - Short for "entity-tag", the ETag is a unique identifier for the resource being requested, typically comprised of the hash of that resource, or a hash of the timestamp the resource was updated. Basically, this lets a client ask smarter questions of the CDNs, like "give me X if it's different than the ETag I already have."

Note: This tag is useful when for when the last modified date is difficult to determine.

Cache-Control:public, max-age=604800
ETag: "4345717de182e49e8d7bd9994af537ed" 

On subsequent browser requests the If-None-Match request header is sent with the ETag value of the last requested version of the resource.

If-None-Match: "4345717de182e49e8d7bd9994af537ed"

Drupal 8 Example

/**
 * Example Even Subscriber.
 */
class AddCustomHTTPheaders implements EventSubscriberInterface {

  /**
   * Sets extra HTTP headers.
   */
  public function onRespond(FilterResponseEvent $event) {
    $response = $event->getResponse();
    $current_user = \Drupal::currentUser();
    // Set Cache-Control for authenticated users
    if ( !$current_user->isAnonymous() ) {
      $response->headers->set('Cache-Control', 'public, max-age: 604800');
    }
  }

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents() {
    $events[KernelEvents::RESPONSE][] = ['onRespond'];
    return $events;
  }

}

Please read How to Register an Event Subscriber in Drupal 8 to learn more about the code example. And download Drupal 8 module to see how this can be implemented.

Useful links

  1. Increasing Application Performance with HTTP Cache Headers
  2. HTTP caching - Web Fundamentals - Google Developers
  3. Analysis of HTTP Performance problems
  4. Bloated Request & Response Headers
  5. Improve Performance with Cache-Control Headers
  6. Caching Tutorial
  7. A Guide to Caching with NGINX
  8. RFC 2616 - 14.9 Cache-Control
May 26 2016
May 26

How to Register an Event Subscriber in Drupal 8Events in Drupal 8 allow for different components of the system to interact and communicate with each other. One system component dispatches the event at an appropriate time; many events are dispatched by Drupal core and the Symfony framework in every request. Other system components can register as event subscribers; when an event is dispatched, a method is called on each registered subscriber, allowing each one to react.

Most of the hooks from previous versions of Drupal were removed in Drupal 8 in favor of Events. Example: hook_init() or hook_boot() which now can be done by registering an event subscriber.

I will use the following structure for the example module:

my_event_subscriber/
  - my_event_subscriber.info.yml
  - my_event_subscriber.services.yml
  - src/
    - EventSubscriber/
      - MyEventSubscriber.php

Standard my_event_subscriber.info.yml file for the module:

name: Register an Event Subscriber
type: module
description: 'Example: How to Register an Event Subscriber in Drupal 8'
core: 8.x
package: Other

Define a service tagged with 'event_subscriber' in the my_event_subscriber.services.yml.

services:
  my_event_subscriber:
    class: '\Drupal\my_event_subscriber\EventSubscriber\MyEventSubscriber'
    tags:
      - { name: 'event_subscriber' }

src/EventSubscriber/MyEventSubscriber.php contains a class that implements \Symfony\Component\EventDispatcher\EventSubscriberInterface

/**
 * @file
 * Contains \Drupal\my_event_subscriber\EventSubscriber\MyEventSubscriber.
 */

namespace Drupal\my_event_subscriber\EventSubscriber;

use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * Event Subscriber MyEventSubscriber.
 */
class MyEventSubscriber implements EventSubscriberInterface {

  /**
   * Code that should be triggered on event specified 
   */
  public function onRespond(FilterResponseEvent $event) {
    // The RESPONSE event occurs once a response was created for replying to a request.
    // For example you could override or add extra HTTP headers in here
    $response = $event->getResponse();
    $response->headers->set('X-Custom-Header', 'MyValue');
  }

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents() {
    // For this example I am using KernelEvents constants (see below a full list).
    $events[KernelEvents::RESPONSE][] = ['onRespond'];
    return $events;
  }

}

Here is a list of KernelEvents constants:

KernelEvents::CONTROLLER; // The CONTROLLER event occurs once a controller was found for handling a request.
KernelEvents::EXCEPTION; // The EXCEPTION event occurs when an uncaught exception appears.
KernelEvents::FINISH_REQUEST; //The FINISH_REQUEST event occurs when a response was generated for a request.
KernelEvents::REQUEST; // The REQUEST event occurs at the very beginning of request dispatching.
KernelEvents::RESPONSE; // The RESPONSE event occurs once a response was created for replying to a request.
KernelEvents::TERMINATE; // The TERMINATE event occurs once a response was sent.
KernelEvents::VIEW; // The VIEW event occurs when the return value of a controller is not a Response instance.

There are more events available. More event constants.

How to dispatch an event

$dispatcher = \Drupal::service('event_dispatcher');
$dispatcher->dispatch($Event_Name, $Optional_Event_Object);

Useful links

May 24 2016
May 24

In this blog post I will briefly overview some of the very useful HTTP response header parameters that will help to secure any website. HTTP Response headers are name-value pairs of strings sent back from a server with the content you requested. More information can be found on the internet.

How to Secure Drupal HTTP Headers

I will cover some of the most important security-related HTTP parameters. The original blog post was written by Scott Helme who is the creator of SecurityHeaders.io. This is a brief overview of his blog post to introduce this technique to our readers.

Most of the header parameters can be set either in your .htaccess file (Apache) or webserver configuration files (preferred).

It is always good to review your response HTTP headers and remove some of them (if the system allows). For instance PHP version or webserver version.

Important: Make sure to QA your website after making the changes to the HTTP headers. Headers such as X-Frame-Options and Access-Control-Allow-Origin may break your site if incorrectly configured.

Also as a result of this blog post I built Drupal 8 module. The module is similar to Security Kit Drupal 7 module.

X-Xss-Protection

This response header can be used to configure a user-agent's built in reflective XSS protection. Currently, only Microsoft's Internet Explorer, Google Chrome and Safari (WebKit) support this header.

Set this in Nginx:
add_header X-Xss-Protection "1; mode=block" always;

Set this in Apache:
Header always set X-Xss-Protection "1; mode=block"

X-Powered-By

The X-Powered-By header gives information on the technology that's supporting the Web Server. This parameter specifies the technology (e.g. PHP etc..) supporting the web application (version details are often in X-Runtime, X-Version, or X-AspNet-Version

To disable this parameter edit your php.ini file. Set expose_php = Off and restart your webserver.

X-Content-Type-Options

This header parameter prevents Google Chrome and Internet Explorer from trying to mime-sniff the content-type of a response away from the one being declared by the server. It reduces exposure to drive-by downloads and the risks of user uploaded content that, with clever naming, could be treated as a different content-type, like an executable.

Set this in Nginx:
add_header X-Content-Type-Options "nosniff" always;

Set this in Apache:
Header always set X-Content-Type-Options "nosniff"

X-Frame-Options

Clickjacking protection. Valid values include DENY meaning your site can't be framed, SAMEORIGIN which allows you to frame your own site or ALLOW-FROM https://example.com/ which lets you specify sites that are permitted to frame your own site.

Set this in Nginx:
add_header X-Frame-Options "SAMEORIGIN" always;

Set this in Apache:
Header always set X-Frame-Options "SAMEORIGIN"

Access-Control-Allow-Origin

Access-Control-Allow-Origin is apart of the Cross Origin Resource Sharing (CORS) specification. This header is used to determine which sites are allowed to access the resource by defining either a single origin or all sites (denoted by a wildcard value). It should be noted that if the resource has the wildcard value set, then the Access-Control-Allow-Credentials option will not be valid and the user-agent's cookies will not be sent in the request. Valid values are:

  • * - Wildcard value allowing any remote resource to access the content of the resource which returned the Access-Control-Allow-Origin header.
  • http://www.example.com - A single serialized origin (http://[host], or https://[host]).

Content Security Policy

This HTTP header parameter allows you to define a whitelist of approved sources of content for your site. By restricting the assets that a browser can load for your site you will have extra level of protection from XSS attacks.

Set this in Nginx:
add_header Content-Security-Policy "default-src https: data: 'unsafe-inline' 'unsafe-eval'" always;

Set this in Apache:
Header always set Content-Security-Policy "default-src https: data: 'unsafe-inline' 'unsafe-eval'"

Read more Content Security Policy - An Introduction

HTTP Strict Transport Security

This policy will enforce TLS on your site and all subdomains for a year.

Set this in Nginx:
add_header Strict-Transport-Security "max-age=31536000; includeSubdomains" always;

Set this in Apache:
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"

Read more HSTS - The missing link in Transport Layer Security

Drupal modules

Download Drupal 8 module (github)

Useful links

  1. List of HTTP header fields
  2. Security HTTP Response Headers
  3. Security through HTTP response headers
  4. Hardening your HTTP response headers
  5. Introduction to HTTP Response Headers for Security
  6. Guidelines for Setting Security Headers
Apr 29 2016
Apr 29

These are instructions on how to setup DigitalOcean droplet to host your personal website. DigitalOcean is a very affordable cloud hosting for developers (starting from $5 for a very simple droplet 512MB Memory / 1 CPU and 20GB disk).

How to Host Drupal 8 on DigitalOcean

DigitalOcean provides great documentation with step by step instruction about how to configure your servers to do what you need.

Certainly by building your own server you won't have the tools that Drupal sepcific hostings provide, such as Acquia or Pantheon and certainly I am recommending to use Drupal specific hosting for your clients because they provide much better support on a various levels, starting from server issues all the way to Drupal-specific issues.

While they have great support and pricing for businesses they don't have affordable plans for personal websites and that is the reason why I am writing this blog post. By following these instructions and configuring the servers yourself you will better understand how webservers work.

I won't rewrite all the instructions, instead I will include links to DigitalOcean's manuals with all the steps.

Note: When you create a droplet (server) you may choose preconfigured droplet with Drupal 8 already installed on it. However I prefer installing everything myself by following the steps you will learn new things.

We will be using Ubuntu 14.04, Ngnix, PHP7, MySQL 5.6. (LEMP stack). Please follow the instructions in the following order:

  1. Initial Server Setup with Ubuntu 14.0
  2. Once you complete initial server configuration make sure your server is secured. Please follow firewall configuration instructions: How To Set Up a Firewall with UFW on Ubuntu 14.04. Basically only allow 80 (http), 443 (https) and 22 (SSH) ports. Please read also UFW Essentials: Common Firewall Rules and Commands
  3. How To Install Linux, nginx, MySQL, PHP (LEMP) stack on Ubuntu 14.04
  4. The link above will only install PHP5. In order to upgrade it to PHP7 use this: How To Upgrade to PHP 7 on Ubuntu 14.04
  5. If you would like to use Memcached with PHP7 follow this instructions: Installing PHP-7 with Memcached
  6. Once you have your server configured begin installing Drupal 8. Please see Nginx configuration settings for Drupal 8

If you would like to use Apache (LAMP stack) for your webserver please use this:

  1. Follow the same server conifguraiton from above.
  2. How To Install Linux, Apache, MySQL, PHP (LAMP) stack on Ubuntu

Once you complete all the steps above you should be able to see your site by accessing http://Your_Server_Public_IP.

There are a great number of instructions how to build high availability servers, of course this will cost more since you have to create multiple droplets, however for a personal site this is not needed. Make sure your servers are in the same datacenter region:

  1. How To Create a High Availability Setup with Corosync, Pacemaker, and Floating IPs on Ubuntu 14.04 read also about Floating IPs: How To Use Floating IPs on DigitalOcean
  2. How To Create a High Availability HAProxy Setup with Corosync, Pacemaker, and Floating IPs on Ubuntu 14.04 (complete the first instruction before starting this)
  3. How To Set Up MySQL Master-Master Replication

More interesting instructions:

  1. How To Set Up Automatic Deployment with Git with a VPS
  2. How To Set Up a Host Name with DigitalOcean
  3. How To Use the DigitalOcean Docker Application
  4. Any many more tutorials.
  5. How To Create an SSL Certificate on Nginx for Ubuntu 14.04
  6. Using SSL Certificates with HAProxy also read How can I use SSL behind a load balancer and still get the client IP?

Other similar cloud hosting providers:

  1. Linode
  2. Amazon EC2
Apr 25 2016
Apr 25

This is a very simple tutorial that could help you with the performance of your custom modules. I will show you how to use Cache API in Drupal 8 and Drupal 7.

Learn the Cache API in Drupal 7 and 8

You don't have to perform heavy calculations every time you need to pull data either from third-party API or from database. Instead run it once and cache it. I personally use caching when I need to run complex SQL queries and third-party integrations (Example: get a list of available forms from Hubspot, or available campaign lists from Mailchimp etc).

In Drupal 8 use the following code structure:

public function mymodule_example_cache() {
  $data = &drupal_static(__FUNCTION__); // Can be replaced with the `__METHOD__`
  $cid = 'mymodule_example:' . \Drupal::languageManager()->getCurrentLanguage()->getId();

  if ($cache = \Drupal::cache()->get($cid)) {
    $data = $cache->data;
  }
  else {
    // This is where you would add your code that will run only
    // when generating a new cache.
    $data = my_module_complicated_calculation();
    \Drupal::cache()->set($cid, $data);
  }
  return $data;
}

In Drupal 7 the following:

function mymodule_example_cache() {
  $data = &drupal_static(__FUNCTION__);
  if (empty($data)) {
    if ($cache = cache_get('mymodule_example')) {
      $data = $cache->data;
    }
    else {
      // This is where you would add your code that will run only
      // when generating a new cache.
      $data = my_module_complicated_calculation();
      cache_set('mymodule_example', $data, 'cache');
    }
  }
  return $data;
}

Useful links

  1. Drupal 8 API: Cache API
  2. Drupal 8 API: function drupal_static
  3. Drupal 7 API: function cache_set
  4. Drupal 7 API: function cache_get
  5. Drupal 7 API: function drupal_static
Apr 18 2016
Apr 18

Drupal 8 RESTful APIThis is a very simple module that demonstrates implementation of a custom RESTful API in Drupal 8. Creating your own API with Drupal 8 has become a routine task that doesn't require a lot of work. However there are a lot of things I am not covering in this blog post, such as user login and user registration etc...

You could also look into Rest module (in core) to see how to use it's plugin API to extend core rest functionality.

I use Postman to test my API endpoints (this is an app that allows you to send POST/GET/PUT/DELETE etc.. requests and see API response).

Test API module (test_api.module) contains the following files:

  • test_api.info.yml - Module .info file
  • test_api.module - Empty module file
  • test_api.routing.yml - Routing file with all the API paths:

    # Test API endpoints
    
    test_api.get:
      path: 'my-api/get.json'
      defaults: { _controller: '\Drupal\test_api\Controller\TestAPIController::get_example' }
      methods:  [GET]
      requirements:
        _access: 'TRUE'
    
    test_api.put:
      path: 'my-api/put.json'
      defaults: { _controller: '\Drupal\test_api\Controller\TestAPIController::put_example' }
      methods:  [PUT]
      requirements:
        _access: 'TRUE'
    
    test_api.post:
      path: 'my-api/post.json'
      defaults: { _controller: '\Drupal\test_api\Controller\TestAPIController::post_example' }
      methods:  [POST]
      requirements:
        _access: 'TRUE'
    
    test_api.delete:
      path: 'my-api/delete.json'
      defaults: { _controller: '\Drupal\test_api\Controller\TestAPIController::delete_example' }
      methods:  [DELETE]
      requirements:
        _access: 'TRUE'
    
    
  • src/Controller/TestAPIController.php - Main controller file:

    /**
     * @file
     * Contains \Drupal\test_api\Controller\TestAPIController.
     */
    
    namespace Drupal\test_api\Controller;
    
    use Drupal\Core\Controller\ControllerBase;
    
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpFoundation\JsonResponse;
    
    /**
     * Controller routines for test_api routes.
     */
    class TestAPIController extends ControllerBase {
    
      /**
       * Callback for `my-api/get.json` API method.
       */
      public function get_example( Request $request ) {
    
        $response['data'] = 'Some test data to return';
        $response['method'] = 'GET';
    
        return new JsonResponse( $response );
      }
    
      /**
       * Callback for `my-api/put.json` API method.
       */
      public function put_example( Request $request ) {
    
        $response['data'] = 'Some test data to return';
        $response['method'] = 'PUT';
    
        return new JsonResponse( $response );
      }
    
      /**
       * Callback for `my-api/post.json` API method.
       */
      public function post_example( Request $request ) {
    
        // This condition checks the `Content-type` and makes sure to 
        // decode JSON string from the request body into array.
        if ( 0 === strpos( $request->headers->get( 'Content-Type' ), 'application/json' ) ) {
          $data = json_decode( $request->getContent(), TRUE );
          $request->request->replace( is_array( $data ) ? $data : [] );
        }
    
        $response['data'] = 'Some test data to return';
        $response['method'] = 'POST';
    
        return new JsonResponse( $response );
      }
    
      /**
       * Callback for `my-api/delete.json` API method.
       */
      public function delete_example( Request $request ) {
    
        $response['data'] = 'Some test data to return';
        $response['method'] = 'DELETE';
    
        return new JsonResponse( $response );
      }
    
    }
    
    

This is how you get headers from the $request object:

$request->headers->get( 'Content-Type' );

This is how you get request body from the $request object:

$request->getContent();

Download Test API from Github

Apr 08 2016
Apr 08

Origin Pull CDN in Drupal 8 Origin pull CDN is a type of CDN where you don't have to upload files to the CDN server instead CDN does it for you. You only rewrite URLs to point to the CDN. When asked for a specific file, the CDN will first go to the original server, pull the file, cache and serve it.

Origin pull CDNs are easy to set up. However, it’s less flexible and can create redundant traffic as files are re-queried before they have been changed. Also it could be slow when files first loaded or browser tries access expired files.

One of our recent projects needed a very simple CDN module for Drupal 8. Drupal 7 already has a great easy-to-use CDN module, however Drupal 8 version is still under development and doesn't have downloadable development version. Quick internet search returned almost nothing promising except for the blog post describing URL alteration in Drupal 8 and CDN implementation.

The example was using deprecated code and after quick code cleanup I was able to make a version that was functioning in the most recent version of Drupal 8. Now I am sharing the module here (Github).

The module is a very simple. Basically it is implementation of two hooks: HOOK_file_url_alter(&$uri) and HOOK_css_alter(&$css).

Once enabled you can set configuration options via settings.php or by accessing admin/config/services/cdn-pull-origin page:

$config['cdn_pull_origin.settings'] = [
  // CDN could be conditionally disabled here.
  'enabled' => TRUE,
  'domain'  => 'https://d111111abcdef8.cloudfront.net',
];

Download

Update: CDN module now available for Drupal 8 https://www.drupal.org/project/cdn

Apr 06 2016
Apr 06

Most of us already familiar with the Migrate module in previous versions of Drupal and I personally have been using it for several years to perform content migrations from different CMS's into Drupal. Migrate module is now part of Drupal 8 core which supposed to make it much easier to use.

Drupal 8 Migration

Unfortunatley this is not 100% true and in order to perform content migration you have to install several additional contrib modules. As of today there is no way to run migrations through interface as it was possible in Drupal 6 or 7 (and personally I don't think you really need the UI).

In order to be able to run migrations in command line you will need to install the following modules:

Migration used to be a piece of configuration, like any other module configuration prior Drupal 8.1, and as of Drupal 8.1 all migrations are plugins.

Migration file contain the source plugin, the processing pipeline, and the destination plugin. The source plugin is responsible for reading rows from some source. The processing pipeline defines how each field in each row will be transformed into a value that is appropriate for the destination. Then the destination plugin takes the processed row and saves it somewhere in Drupal (it could be a user or any other entity).

Drupal to Drupal 8 migration is fairly simple task, in most cases it is one-to-one. Drupal 8 already contains migration templates. Migrate Drupal (migrate_drupal) module is the module that lets you to migrate from older versions of Drupal to Drupal 8 and this might not work well if you're using contributed modules that don't have their Drupal 8 versions.

However migration template help a lot when you partially migrating content from your previous versions of Drupal. For instance you need to migrate only certain taxonomy vocabularies, terms, users or any other data.

Example templates could be found in core modules: node, user, taxonomy, file and etc. Migration templates are located in src/Plugin/migrate/source/d6/ or src/Plugin/migrate/source/d7/.

In this blog post I will show simple examples of YAML templates and Migrate API classes.

I usually use the following structure for my migration modules:

- my_migration/
- my_migration.info.yml
- my_migration.module
  - config/
    - install/
      - migrate.migration.my_migration_blog.yml
      - migrate.migration.my_migration_tags.yml
      - migrate.migration.my_migration_image.yml
  - src/
    - Plugin/migrate/source/
      - Node_Blog.php
      - Term_Tags.php
      - File_Image.php

Node

This is a simple example of how to migrate Blogs from previous version of Drupal to Drupal 8.

migrate.migration.my_migration_blog.yml

langcode: en
status: true
migration_group: Drupal 7 to Drupal 8
dependencies:
  config:
    - migrate.migration.my_migration_blog
  module:
    - my_migration_blog
    - node
id: my_migration_blog
migration_tags:
  - 'Drupal 7'
label: 'Blogs'
source:
  plugin: my_migration_blog
  node_type: blog
process:
  type: type
  title: title
  langcode:
    plugin: default_value
    source: language
    default_value: und
  status: status
  created: created
  changed: changed
  path: alias
  body: 
    plugin: iterator
    source: body
    process:
      value: value
      format: 
        plugin: default_value
        default_value: html_pure
  field_image:
    plugin: migration
    migration: my_migration_image
    source: field_image_fid
  field_tags:
    plugin: migration
    migration: my_migration_tags
    source: field_tags_tid
destination:
  plugin: entity:node
  node_type: blog
migration_dependencies: 
  required:
    - my_migration_tags
    - my_migration_image

Node_Blog.php

/**
 * @file
 * Contains \Drupal\my_migration\Plugin\migrate\source\Node_Blogs.
 */

namespace Drupal\my_migration\Plugin\migrate\source;

use Drupal\migrate\Row;
use Drupal\node\Plugin\migrate\source\d7\Node;

/**
 * Drupal 7 node source from database.
 *
 * @MigrateSource(
 *   id = "my_migration_blog",
 *   source_provider = "node"
 * )
 */
class Node_Blogs extends Node {

  /**
   * {@inheritdoc}
   */
  public function prepareRow(Row $row) {
    // Destination conent type.
    if (isset($this->migration->get('destination')['node_type'])) {
      $row->setSourceProperty('type', $this->migration->get('destination')['node_type']);
    }
    // Get Field API field values.
    $nid = $row->getSourceProperty('nid');
    $vid = $row->getSourceProperty('vid');
    foreach (array_keys($this->getFields('node', $row->getSourceProperty('type'))) as $field) {
      $row->setSourceProperty($field, $this->getFieldValues('node', $field, $nid, $vid));
    }
    // Migrate URL alias.
    $alias = db_select('url_alias', 'ua')
      ->fields('ua', ['alias'])
      ->condition('ua.source', 'node/' . $nid)
      ->execute()
      ->fetchField();
    if (!empty($alias)) {
      $row->setSourceProperty('alias', '/' . $alias);
    }
    // Blog image.
    $image_fid = [];
    foreach ($row->getSourceProperty('field_image') as $item) {
      $image_fid[] = $item['fid'];
    }
    // Taxonomy tags.
    $field_tags_tid = [];
    foreach ($row->getSourceProperty('field_tags') as $item) {
      $field_tags_tid[] = $item['tid'];
    }
    $row->setSourceProperty('field_image_fid', $image_fid);
    $row->setSourceProperty('field_tags_tid', $field_tags_tid);
    return parent::prepareRow($row);
  }

  /**
   * {@inheritdoc}
   */
  public function fields() {
    $fields = parent::fields();
    $fields += [
      'field_image_fid' => $this->t('Image'),
      'field_tags_tid'  => $this->t('Tags Terms'),
    ];
    return $fields;
  }

}

Taxonomy

Migrate Tags terms. Please note this migration script won't migrate terms hierarchy.

migrate.migration.my_migration_tags.yml

id: my_migration_tags
status: true
langcode: en
migration_group: Taxonomies
dependencies:
  module:
    - taxonomy
label: Migrate Tags taxonomy terms
source:
  plugin: my_migration_tags
destination:
  plugin: entity:taxonomy_term
process:
  vid:
    plugin: default_value
    default_value: tags
  name: name
  description: description
  weight: weight

Term_Tags.php

/**
 * @file
 * Contains \Drupal\my_migration\Plugin\migrate\source\Term_Tags.
 */

namespace Drupal\my_migration\Plugin\migrate\source;

use Drupal\migrate\Plugin\migrate\source\SqlBase;

/**
 * Taxonomy: Tags.
 *
 * @MigrateSource(
 *   id = "my_migration_tags"
 * )
 */
class Term_Tags extends SqlBase {

  /**
   * {@inheritdoc}
   */
  public function query() {
    $query = $this->select('taxonomy_term_data', 'td');
    $query->join('taxonomy_index', 'ti', 'ti.tid = td.tid');
    $query->join('taxonomy_vocabulary', 'tv', 'tv.vid = td.vid');
    $query->join('node', 'n', 'n.nid = ti.nid');
    $query->fields('td', ['tid', 'name', 'description', 'weight'])
      ->distinct()
      ->condition('n.type', 'blog')
      ->condition('tv.machine_name', 'tags');
    return $query;
  }

  /**
   * {@inheritdoc}
   */
  public function fields() {
    return [
      'name'        => $this->t('Category name'),
      'description' => $this->t('Description'),
      'weight'      => $this->t('Weight'),
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function getIds() {
    return [
      'tid' => [
        'type'  => 'integer',
        'alias' => 'td',
      ],
    ];
  }

}

Files

Example of how to migrate images.

migrate.migration.my_migration_image.yml

id: my_migration_image
status: true
migration_group: Files
dependencies:
  module:
    - file
label: Migrate Blog Images
process:
  filename: filename
  uri: uri
  filemime: filemime
  status: status
  created: timestamp
  changed: timestamp
  uid: uid
  alt: alt
source:
  plugin: my_migration_image
destination:
  plugin: entity:file
  source_path_property: filepath
  urlencode: true
  source_base_path: /path/to/source/files/

File_Image.php

/**
 * @file
 * Contains \Drupal\my_migration\Plugin\migrate\source\File_Image.
 */

namespace Drupal\my_migration\Plugin\migrate\source;

use Drupal\file\Plugin\migrate\source\d7\File;
use Drupal\Core\Database\Query\Condition;
use Drupal\migrate\Row;

/**
 * File: Blog images.
 *
 * @MigrateSource(
 *   id = "my_migration_image"
 * )
 */
class File_Image extends File {

  /**
   * {@inheritdoc}
   */
  public function query() {
    $query = $this->select('file_managed', 'fm');
    $query->join('field_data_field_image', 'fi', 'fi.field_image_fid = fm.fid');
    $query->join('node', 'n', 'n.nid = ti.nid');
    $query->fields('fm', ['fid', 'uid', 'filename', 'uri', 'filemime', 'status', 'timestamp'])
      ->distinct()
      ->condition('n.type', 'blog')
      ->orderBy('n.changed', 'DESC');

    // Filter by scheme(s), if configured.
    if (isset($this->configuration['scheme'])) {
      $schemes = array();
      // Accept either a single scheme, or a list.
      foreach ((array) $this->configuration['scheme'] as $scheme) {
        $schemes[] = rtrim($scheme) . '://';
      }
      $schemes = array_map([$this->getDatabase(), 'escapeLike'], $schemes);

      // uri LIKE 'public://%' OR uri LIKE 'private://%'
      $conditions = new Condition('OR');
      foreach ($schemes as $scheme) {
        $conditions->condition('uri', $scheme . '%', 'LIKE');
      }
      $query->condition($conditions);
    }
    return $query;
  }

}

Finally once you have migration module ready you run the following Drush commands:

drush ms to see the list of all migrations and drush mi --all - to run all migrations. You could also run migrations individually: drush mi [migration_name]

Full list of Drush migrate commands:

  • migrate-status - Lists migrations and their status.
  • migrate-import - Performs import operations.
  • migrate-rollback - Performs rollback operations.
  • migrate-stop - Cleanly stops a running operation.
  • migrate-reset-status - Sets a migration status to Idle if it's gotten stuck.
  • migrate-messages - Lists any messages associated with a migration import.

More links about migration in Drupal 8:

Mar 30 2016
Mar 30

In previous versions of Drupal you would see values from settings.php on the coniguration pages. For instance $conf['mymodule_setting_value'] = 'My Value'; would display "My Value" in the text field and whenever you would try make a change to the field and try hit save button the value of the field would reset to the value specified in settings.php . However in Drupal 8 overridinen configuration form values in settings won't reflect in the form.

Overriding Drupal 8 Configuration Values via Settings.php

There is an issue on Drupal.org for that and developers already found a solution and it should be fixed in Drupal core soon.

Below I am going to provide an example of a simple config form value override in settings.php .

Drupal 8 configuraiton form example:

/**
 * @file
 * Contains \Drupal\mymodule\Form\BasicSettingsForm.
 */

namespace Drupal\mymodule\Form;

use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\FormStateInterface;

/**
 * Form builder for the mymodule basic settings form.
 */
class BasicSettingsForm extends ConfigFormBase {

  /**
   * {@inheritdoc}
   */
  public function getFormId() {
    return 'mymodule_basic_settings_form';
  }

  /**
   * {@inheritdoc}
   */
  protected function getEditableConfigNames() {
    return ['mymodule.settings'];
  }

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state) {
    $config = $this->config('mymodule.settings');

    $form['text_val'] = [
      '#type' => 'textfield',
      '#title' => $this->t('My Module setting'),
      '#default_value' => $config->get('text_val'),
    ];

    $form['group_setting1'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Setting 1'),
      '#default_value' => $config->get('group.setting1'),
    ];

    $form['group_setting2'] = [
      '#type' => 'textfield',
      '#title' => $this->t('Setting 2'),
      '#default_value' => $config->get('group.setting2'),
    ];

    return parent::buildForm($form, $form_state);
  }

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state) {
    parent::submitForm($form, $form_state);
    $config = $this->config('mymodule.settings');
    $config->set('text_val', $form_state->getValue('text_val'));
    $config->set('group.setting1', $form_state->getValue('group_setting1'));
    $config->set('group.setting2', $form_state->getValue('group_setting2'));
    $config->save();
  }

}

To override configuration values use the following format in settings.php :

$config['mymodule.settings'] = [
  // Simple text value override.
  'text_val' => 'Value from settings.php',
  // Groupped values.
  'group' => [
    'setting1'   => 'Value 1',
    'setting2'   => 'Value 2',
  ],
];

You could also use drush to see the values of your configuration forms by using the following command:

drush cget mymodule.settings 

$settings variable

You could also use $settings in settings.php to control your module settings or any other conditional logic.

Add the following to settings.php

$settings['my_setting'] = 'My Setting Value';

And this is how you would access the variable in your code:

use Drupal\Core\Site\Settings;

function my_function() {
  $mySetting = Settings::get('my_setting', NULL);
}
Mar 24 2016
Mar 24

SimpleSAML PHP Drupal AcquiaThese are instructions on how to configure SimpleSAMLphp library for Drupal 8 on Acquia, the configuration settings may vary depending on the ADFS configuration.

Requirements

  1. Download SimpleSAMLphp (I used version 1.14.2, latest version is always recommended).
  2. Drupal 8 site (latest version recommended).
  3. simpleSAMLphp Authentication Drupal 8 module.
  4. Drupal 8 site running on Acquia.

Install SimpleSAMLphp

  • Extract the SimpleSAML library to the root directory of your repository alongside docroot, acquia-utils and library.

  • In your docroot directory, create a symbolic link (name it simplesaml) that points to the to the simplesamlphp-1.14.2/www directory in theSimpleSAMLphp library.

$ cd docroot
$ ln -s ../simplesamlphp-1.14.2/www ./simplesaml
$ git add ../simplesamlphp-1.14.2
$ git add simplesaml
$ git commit -m "Add SimpleSAMLphp library. Add SimpleSAML symlink."
  • Generate certificates as needed and add them to your repository /simplesamlphp-1.14.2/cert

Important: Make sure you are using correct encryption. Microsoft announced that they wouldn't be accepting SHA1 certificates after 2016. Read more. Generate SHA256 certificates:

$ cd cert
$ openssl req -x509 -sha256 -nodes -days 3652 -newkey rsa:2048 -keyout saml.pem -out saml.crt

SimpleSAMLphp Configuration

Set up your /simplesamlphp-1.14.2/config/config.php file. Acquia provides a code snippet that helps properly setup all the variables required by the library. The code snippet shoud go AFTER the config = array() variable:

$config = (
  // Default Library settings template.
  // I would not recommend to change any of those settings
  // Instead just override them below the `$config` varaible.
);

// All custom changes below. Modify as needed.
// Defines account specific settings.
// $ah_options['database_name'] should be the Acquia Cloud workflow database name which
// will store SAML session information.set
// You can use any database that you have defined in your workflow.
// Use the database "role" without the stage ("dev", "stage", or "test", etc.)
$ah_options = array(
  'database_name' => '[DATABASE-NAME]',
  'session_store' => array(
    'prod' => 'memcache', // This can be either `memcache` or `database`
    'test' => 'memcache', // This can be either `memcache` or `database`
    'dev'  => 'database', // This can be either `memcache` or `database`
  ),
);
// Base URL
$config['baseurlpath'] = 'https://'. $_SERVER['HTTP_HOST'] .'/simplesaml/';
// Remove memcache prefix
unset($config['memcache_store.prefix']);
// Set some security and other configs that are set above, however we
// overwrite them here to keep all changes in one area
$config['technicalcontact_name'] = "Technical Contact Name";
$config['technicalcontact_email'] = "[email protected]";
// Change these for your installation
$config['secretsalt'] = '[YOUR-SECERET-SALT]';
$config['auth.adminpassword'] = '[ADMIN-PASSWORD]';
$config['admin.protectindexpage'] = TRUE;
// Prevent Varnish from interfering with SimpleSAMLphp.
setcookie('NO_CACHE', '1');
if (empty($_ENV['AH_SITE_ENVIRONMENT'])) {
  // add any local configuration here
} else {
  $ah_options['env'] = $_ENV['AH_SITE_ENVIRONMENT'];
  $config = acquia_logging_config($config);
  $config = acquia_session_store_config($config, $ah_options);
}
function acquia_session_store_config($config, $ah_options) {
  if ($ah_options['session_store'][$ah_options['env']] == 'memcache') {
    $config = mc_session_store($config);
  } elseif ($ah_options['session_store'][$ah_options['env']] == 'database') {
    $config = sql_session_store($config, $ah_options['database_name']);
  }
  return $config;
}
function acquia_logging_config($config) {
  // Set log location, as specified by Acquia
  $config['logging.handler'] = 'file';
  $config['loggingdir'] = dirname($_ENV['ACQUIA_HOSTING_DRUPAL_LOG']);
  $config['logging.logfile'] = 'simplesamlphp-' . date("Ymd") . '.log';
  return $config;
}
function mc_session_store($config) {
  $config['store.type'] = 'memcache';
  $config['memcache_store.servers'] = mc_info();
  return $config;
}
function mc_info() {
  $creds_json = file_get_contents('/var/www/site-php/' . $_ENV['AH_SITE_NAME'] . '/creds.json');
  $creds = json_decode($creds_json, TRUE);
  $mc_server = array();
  $mc_pool = array();
  foreach ($creds['memcached_servers'] as $fqdn) {
    $mc_server['hostname'] = preg_replace('/:.*?$/', '', $fqdn);
    array_push($mc_pool, $mc_server);
  }
  return array($mc_pool);
}
function sql_session_store($config, $database_name) {
  $creds = db_info($database_name);
  $config['store.type'] = 'sql';
  $config['store.sql.dsn'] = sprintf('mysql:host=%s;port=%s;dbname=%s', $creds['host'], $creds['port'], $creds['name']);
  $config['store.sql.username'] = $creds['user'];
  $config['store.sql.password'] = $creds['pass'];
  $config['store.sql.prefix'] = 'simplesaml';
  return $config;
}
function db_info($db_name) {
  $creds_json = file_get_contents('/var/www/site-php/' . $_ENV['AH_SITE_NAME'] . '/creds.json');
  $databases = json_decode($creds_json, TRUE);
  $db = $databases['databases'][$db_name];
  $db['host'] = ($host = ah_db_current_host($db['db_cluster_id'])) ? $host : key($db['db_url_ha']);
  return $db;
}
function ah_db_current_host($db_cluster_id) {
  require_once("/usr/share/php/Net/DNS2_wrapper.php");
  try {
    $resolver = new Net_DNS2_Resolver(array('nameservers' => array('127.0.0.1', 'dns-master')));
    $response = $resolver->query("cluster-{$db_cluster_id}.mysql", 'CNAME');
    $cached_id = $response->answer[0]->cname;
  }
  catch (Net_DNS2_Exception $e) {
    $cached_id = "";
  }
  return $cached_id;
}

  • The latest version of the Acquia SAML config snippet can be downloaded from here: https://gist.github.com/acquialibrary/8059715
  • Notice: $config['baseurlpath'] = 'https://'. $_SERVER['HTTP_HOST'] .'/simplesaml/';. This forces SAML library to server over HTTPS.
  • Now you should be able to login to your SimpleSAMLphp interface.

    • Visit https://example.prod.acquia-sites.com/simplesaml
    • Enter the password from the config.php value of the auth.adminpassword parameter.
    • Make sure Checking your PHP installation on the config page has no red flags. https://example.prod.acquia-sites.com/simplesaml/module.php/core/frontpage_config.php
  • Next, configure /simplesamlphp-1.14.2/config/authsources.php file

$env = !empty($_ENV['AH_SITE_ENVIRONMENT']) ? '-' . $_ENV['AH_SITE_ENVIRONMENT'] : '';

  $config = array(
  'admin' => array(
    'core:AdminPassword',
  ),
  'default-sp' => array(
   'saml:SP',

   // You can get this from ADFS Federation file
   // Contact your ADFS administrator
   // to obtain this information.
   'entityID'             => 'urn:drupal:adfs-saml' . $env,
   'idp'                  => 'http://example.org/adfs/services/trust',
   'NameIDPolicy'         => null,
   'redirect.sign'        => true,
   'assertion.encryption' => true,
   'sign.logout'          => true,

   // Generate using openssl, @see example above.
   // These are the certs from `/cert` directory.
   'privatekey'           => 'saml.pem',
   'certificate'          => 'saml.crt',
   // Defaults to SHA1 (http://www.w3.org/2000/09/xmldsig#rsa-sha1)
   'signature.algorithm'  => 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
  ),
);
  • Next you will need a Federation metadata file from the ADFS. Contact your ADFS administrator to generate the file.

  • After you obtain the federation metadata file use the XML to SimpleSAMLphp metadata converter to generate other config files. The converter is a part of the SimpleSAMLphp library and can be accessed through web: https://example.prod.acquia-sites.com/simplesaml/admin/metadata-converter.php

The result should look similar to the following:

saml20-sp-remote

Put the results of this section into /simplesamlphp-1.14.2/metadata/saml20-sp-remote.php

$metadata['http://example.org.org/adfs/services/trust'] = array (
  'entityid' => 'http://example.org/adfs/services/trust',
  'contacts' => 
  array (
    0 => 
    array (
      'contactType' => 'support',
    ),
  ),
  'metadata-set' => 'saml20-sp-remote',
  'AssertionConsumerService' => 
  array (
    0 => 
    array (
      'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
      'Location' => 'https://example.org/adfs/ls/',
      'index' => 0,
      'isDefault' => true,
    ),
    1 => 
    array (
      'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact',
      'Location' => 'https://example.org/adfs/ls/',
      'index' => 1,
    ),
    2 => 
    array (
      'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
      'Location' => 'https://example.org/adfs/ls/',
      'index' => 2,
    ),
  ),
  'SingleLogoutService' => 
  array (
    0 => 
    array (
      'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
      'Location' => 'https://example.org/adfs/ls/',
    ),
    1 => 
    array (
      'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
      'Location' => 'https://example.org/adfs/ls/',
    ),
  ),
  'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
  'keys' => 
  array (
    0 => 
    array (
      'encryption' => true,
      'signing' => false,
      'type' => 'X509Certificate',
      'X509Certificate' => .... certificate string ...',
    ),
    1 => 
    array (
      'encryption' => false,
      'signing' => true,
      'type' => 'X509Certificate',
      'X509Certificate' => '.... certificate string ...',
    ),
  ),
  'saml20.sign.assertion' => true,
);

saml20-idp-remote

Put the results of this section into /simplesamlphp-1.14.2/metadata/saml20-idp-remote.php

$metadata['http://example.org/adfs/services/trust'] = array (
  'entityid' => 'http://example.org/adfs/services/trust',
  'contacts' => 
  array (
    0 => 
    array (
      'contactType' => 'support',
    ),
  ),
  'metadata-set' => 'saml20-idp-remote',
  'SingleSignOnService' => 
  array (
    0 => 
    array (
      'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
      'Location' => 'https://example.org/adfs/ls/',
    ),
    1 => 
    array (
      'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
      'Location' => 'https://example.org/adfs/ls/',
    ),
  ),
  'SingleLogoutService' => 
  array (
    0 => 
    array (
      'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
      'Location' => 'https://example.org/adfs/ls/',
    ),
    1 => 
    array (
      'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
      'Location' => 'https://example.org/adfs/ls/',
    ),
  ),
  'ArtifactResolutionService' => 
  array (
  ),
  'keys' => 
  array (
    0 => 
    array (
      'encryption' => true,
      'signing' => false,
      'type' => 'X509Certificate',
      'X509Certificate' => '.... certificate string ...',
    ),
    1 => 
    array (
      'encryption' => false,
      'signing' => true,
      'type' => 'X509Certificate',
      'X509Certificate' => '.... certificate string ...',
    ),
  ),
);

  • Finally you will have to provide information to your ADFS administrator. The SimpleSAMLphp library can generate the federation metadata file for you. Here this the link:

https://example.prod.acquia-sites.com/simplesaml/module.php/saml/sp/metadata.php/default-sp?output=xhtml

The federation metadata XML file should look something like this:

<?xml version="1.0"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="urn:drupal:adfs-saml">
  <md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:1.1:protocol urn:oasis:names:tc:SAML:2.0:protocol">
    <md:KeyDescriptor use="signing">
      <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
        <ds:X509Data>
          <ds:X509Certificate>.... certificate string .....</ds:X509Certificate>
        </ds:X509Data>
      </ds:KeyInfo>
    </md:KeyDescriptor>
    <md:KeyDescriptor use="encryption">
      <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
        <ds:X509Data>
          <ds:X509Certificate> .... certificate string .....</ds:X509Certificate>
        </ds:X509Data>
      </ds:KeyInfo>
    </md:KeyDescriptor>
    <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://example.prod.acquia-sites.com/simplesaml/module.php/saml/sp/saml2-logout.php/default-sp"/>
    <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://example.prod.acquia-sites.com/simplesaml/module.php/saml/sp/saml2-logout.php/default-sp"/>
    <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://example.prod.acquia-sites.com/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp" index="0"/>
    <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:1.0:profiles:browser-post" Location="https://example.prod.acquia-sites.com/simplesaml/module.php/saml/sp/saml1-acs.php/default-sp" index="1"/>
    <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="https://example.prod.acquia-sites.com/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp" index="2"/>
    <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:1.0:profiles:artifact-01" Location="https://example.prod.acquia-sites.com/simplesaml/module.php/saml/sp/saml1-acs.php/default-sp/artifact" index="3"/>
  </md:SPSSODescriptor>
  <md:ContactPerson contactType="technical">
    <md:GivenName>Minnur</md:GivenName>
    <md:SurName>Yunusov</md:SurName>
    <md:EmailAddress>[email protected]</md:EmailAddress>
  </md:ContactPerson>
</md:EntityDescriptor>

Drupal Configuration

Basic setup

  • Open http://example.prod.acquia-sites.com/admin/config/people/simplesamlphp_auth
  • Check Activate authentication via SimpleSAMLphp option.
  • Configure settings.php. Add the following:
    // SimpleSAMLphp configuration
    # Provide universal absolute path to the installation.
    if (isset($_ENV['AH_SITE_NAME']) && is_dir('/var/www/html/' . $_ENV['AH_SITE_NAME'] . '/simplesamlphp-1.14.2')) {
      $settings['simplesamlphp_dir'] = '/var/www/html/' . $_ENV['AH_SITE_NAME'] . '/simplesamlphp-1.14.2';
    }
    else {
      // Local SAML path.
      if (is_dir(DRUPAL_ROOT . '/../simplesamlphp-1.14.2')) {
        $settings['simplesamlphp_dir'] = DRUPAL_ROOT . '/../simplesamlphp-1.14.2';
      }
    }
    
  • Modify your .htaccess file by adding the following:
    
      # Copy and adapt this rule to directly execute PHP files in contributed or
      # custom modules or to run another PHP application in the same directory.
      RewriteCond %{REQUEST_URI} !/core/modules/statistics/statistics.php$
    + # Allow access to simplesaml paths
    + RewriteCond %{REQUEST_URI} !^/simplesaml
      # Deny access to any other PHP files that do not match the rules above.
      RewriteRule "^.+/.*\.php$" - [F]
    
  • SimpleSAMLphp_auth module settings. I personally recommend to store configuration for SimpleSAMLphp_auth module settings in settings.php.
    $config['simplesamlphp_auth.settings'] = [
      // Basic settings.
      'activate'                => TRUE, // Enable or Disable SAML login.
      'auth_source'             => 'default-sp',
      'login_link_display_name' => 'Login with your SSO account',
      'register_users'          => TRUE,
      'debug'                   => FALSE,
      // Local authentication.
      'allow' => [
        'default_login'         => TRUE,
        'set_drupal_pwd'        => TRUE,
        'default_login_users'   => '',
        'default_login_roles'   => [
          'authenticated' => FALSE,
          'administrator' => 'administrator',
        ],
      ],
      'logout_goto_url'         => '',
      // User info and syncing.
      // `unique_id` is specified in Transient format, otherwise this should be `UPN`
      // Please talk to your SSO adminsitrators about which format you should be using.
      'unique_id'               => 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn', 
      'user_name'               => 'uid',
      'mail_attr'               => 'mail',
      'sync' => [
        'mail'      => FALSE,
        'user_name' => FALSE,
      ],
    ];
    

Local authentication

  • Check Allow authentication with local Drupal accounts option. Otherwise you other users won't be able to login to Drupal.
  • Hit Save configuration

User info and syncing

  • Usually we set username as user's email address, if this is the case for you please try add the following:
    • http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress
  • Unique identifier for the user
    • http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn
  • User mail address
    • http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress
  • Hit Save configuration

ADFS Configuration

See Useful Links below. Some of those links contain information on how to configure the ADFS.

Test SimpleSAMLphp and Drupal

  • Try login, use the following URL address to test:
    • https://example.prod.acquia-sites.com/saml_login
  • If you would like to support login via SAML only I would recommend you to replace Login (user/login) path with /saml_login

Troubleshoot

  1. Login works but logout doesn't.
    • Make sure your certificates are generated using correct encryption.
    • Make sure your ADFS configuration is set to use correct encryption. In my case it was set to SHA256 and the certificates were generated using SHA1.
  2. If you're using SHA256 certificates make sure to specify encryption algorithm in config/authsources.php by adding signature.algorithm. See SAML 2.0 Options section.
  3. Get errors on login.
    • Make sure ADFS has correct claim rules. All fields should be in it's own Claim Rule.
  4. Make sure to restart ADFS service every time you make a change.
  5. If login used to work but stopped working after a year/month etc. Most likely the certificate expiration date was set to be a year/month. To solve this issue simply regenerate the certificate. See example above.
  6. If you get Permission Denied message when accessing https://example.prod.acquia-sites.com/simplesaml make sure you properly modified your .htaccess file. More help with the simpleSAMLphp Authentication for Drupal 8 can be found in the module's README file.
  7. In case if you can't access https://example.prod.acquia-sites.com/simplesaml page and you see some technical issue message, try changing session_store from memcache to database.
  8. Can't login and SimpleSAMLphp is not returning attributes? Try clearing Browser cookies and login one more time.

Useful links

Nov 09 2015
Nov 09

Apple News module provides an easy way to get your Drupal site's content into Apple News.

The Apple News module contains the following submodules:

  • Apple News Example - A fully functional example to use as a base for your own module with a fully styled node export.
  • Apple News Extra - Additional preconfigured destinations including Instagram, Twitter and embedded video and other components.

Before You Start

Before you start you must have a working Drupal 7 site, permissions to administer modules and add code, and that you meet the Minimum Requirements.

To install Drupal see these guides: Quick installation and Quick installation for developers.

Drush is not mandatory but will make for an easier, faster installation.

Minimum Requirements

These are the minimum requirements for the Apple News module.

  • PHP version 5.4 and above. Version 5.3 and below will cause a fatal error.
  • Drupal 7. The late version of Drupal 7 is recommended.
  • PHP compiled with cURL support. Check by going to /admin/reports/status/php on your Drupal site and looking for 'curl'. If not enabled, do a Google search for 'enable curl php [your server type]'.
  • (Optional) Drush 5.9 and above. If Drush is installed, follow the Drush-based Installation guide from README.md files. Otherwise, follow the Manual Installation guide

Become Apple News Publisher

To publish content from Drupal to News, a publisher must first sign up for News Publisher and obtain Apple News API credentials from Apple.

Install Drupal module

Download Apple News module and its dependencies:

The module also depends on two PHP libraries (to see required versions please enable Apple News module):

After you enable the Apple News module you will see a warning message about missing libraries. Please download required versions or try auto-install (experimental feature). If you used auto-install feature please try refresh the page after you press Download and Unzip button and clear Drupal cache.

Please install Views module if you would like to see Apple News published content and build your custom reports.

Apple News modules

For more options on how to install the module please see README.md file.

Configure Apple News module

Follow these configuration instructions to start publishing your content.

Initial Settings configuration

  1. Visit https://www.icloud.com/newspublisher/ to get your credentials and create a news channel that your Drupal site will use.

  2. In your Drupal site, navigate to the "Apple news credentials page" (admin/config/content/applenews/settings) and add your Apple News credentials. Apple News module settings

  3. In your Drupal site, navigate to the "Apple news channels page" (admin/config/content/applenews/settings/channels) and add a channel ID from your Apple account. Please add one ID at a time. The channels are validated by the Apple credentials that you added to your Drupal site. If valid, it will fetch the channel information and add them to your site's list of channels. Configure Channels

Export Configuration

An export is code that defines how to transform data in a Drupal site so it can be pushed to Apple News. The Apple News module defines a simple export, while the applenews_example module defines a more usable style.

To get started, we suggest enabling the applenews_example module and using that as a starting point.

  1. In your Drupal site, navigate to the "Apple news export manager page" (admin/config/content/applenews).
  2. Add new export.
    • Nodes - Configurable node export - you custom layout with three default components enabled.
    • Nodes - Example styled node exprot - This is example layout and contains some pre-styled components.
  3. The next step is to configure your export. Export content to Apple News
  4. On the Edit page, the minimum requirements to properly configure a channel to an Apple News channel are:
    1. Under "General Settings" set "Content types", select the content types that should be processed with this channel.
    2. Under "Components" in the "Add new component" field, select a component. Apple News components
    3. Under "Metadata" set "Default channels and sections", select the channel (Apple News Channel) that this export will be tied to. This export channel will get nodes, process them, and send them to the selected channel for display in the Apple News app. Apple News module metadata settings
    4. Click Save Changes.
    5. After saving, you will see "edit" and "delete" options to the right of the new components we just added. Click on "edit".
    6. Configure the component. Most components will require that you specify source fields and the component will use the data in those fields as content in the component.
    7. Click Save Changes.

Node Configuration

Once a content type is enabled in an export/channel, the option to add the individual post is in the node's add/edit page. If a content type is not added to any channel export, these options will not be available on the node add/edit page.

  1. To add a node to the channel sent to Apple, select "Publish to Apple" in the "Apple News" tab. If you want to temporarily stop publishing to Apple, or make revisions to the post before publishing or re-publishing to Apple, deselect this checkbox. It is the equivalent to the "Publish/Unpublish" feature with Drupal nodes.

  2. Select one or more channels from the available list.

  3. For each selected channel, select an available "section" that it belongs to. ("Sections" are created on apple.com, where you initially created the channel).

  4. Once a node is initially published to an Apple news channel, it will display a general information section showing post date, share URL, and the section and channel where it is published.

Publish individual content to Apple News

Preview a Post Before Publishing

If you want to preview a post before sending it to Apple, you will need to first download and install the Apple "News Preview" Application.

  1. After saving the node, return to the node's edit page
  2. Find the "Apple News" tab, and click the "Download" link under "Preview." This will download a folder containing the specially formatted file needed by the News Preview App.
  3. Drag the whole folder into the App icon to open, and it will display the page just as the Apple News App will be displaying it.

Preview content

Delete a post from publishing

If you want to delete a post from a channel, but not delete the post itself, There is a delete link in the "Apple News" tab.

Troubleshoot

If you are having trouble installing the module or its dependencies, review the common scenarios below.

  1. Problem: I'm getting the error message that includes:

    shell Fatal error: undefined '['

    Solution: This means that your version of PHP does not meet minimum standards. Version 5.3 and below are not able to process the bracketed php formatting of the AppleNewsAPI library. Updating your version of PHP to 5.4 and above will fix this.

  2. Problem: I'm getting the error message that includes:

    shell SSL certificate problem: unable to get local issuer certificate

    Solution: This is a mis-configuration in your server setup. Depending on what server OS you are using, the fix is different. Please see this StackOverflow post "curl: (60) SSL certificate : unable to get local issuer certificate" for more information on possible fixes specific for your system.

  3. Problem: I'm getting the error message:

    shell Please download PHP-Curl-Class (version 4.6.9) library to sites/all/libraries/php-curl-class

    Solution: This means that the library has not been downloaded, the wrong version is in place, or the folder for the library is labeled wrong. Double check that the library was downloaded into sites/all/libraries/php-curl-class/[files start here]. Check that the version is 4.6.9 by opening up the composer.json file and search for "version": "4.6.9". Lastly, if still not resolved, make sure the folder is named php-curl-class and NOT something like php-curl-class-master or php-curl-class-4.6.9.

  4. Problem: I'm getting the error message:

    shell Please download AppleNewsAPI (version 0.3.7) library to sites/all/libraries/AppleNewsAPI

    Solution: This means that the library has not been downloaded, the wrong version is in place, or the folder for the library is labeled wrong. Double check that the library was downloaded into sites/all/libraries/AppleNewsAPI/[files start here]. Check that the version is 0.3.7 by opening up the composer.json file and search for "version": "0.3.7". Lastly, if still not resolved, make sure the folder is named AppleNewsAPI and NOT something like AppleNewsAPI-master or AppleNewsAPI-0.3.7.

Please consult drupal.org for any issues outside of this scope.

Useful links

Oct 16 2015
Oct 16

These are instructions on how to configure SimpleSAMLphp library on Pantheon to work with PingOne. To get started, you have to have access to PingIdentity's product called PingFederate.

In this tutorial I am going to use the same version of the SimpleSAMLphp (1.11.0) that I used in my previous blog post (highly recommended reading before you start integrating PingOne).

Requirements

  1. The same requirements as in ADFS and SimpleSAMLphp with Drupal.
  2. Access to the PingFederate product.

Install SimpleSAMLphp

See Install SimpleSAMLphp in ADFS and SimpleSAMLphp with Drupal.

Create PingOne application

You have to create an application so you can download the federation metadata file and use it to configure the SimpleSAMLphp library. To do so login to PingOne.

  1. Navigate to the Applications page and add a New SAML Application. New SAML Application
  2. Fill out the Application Details and press Continue to Next Step. Application name and Application description are required.
    Application details
  3. Next you have to Download the SAML metadata and use that XML to generate configuration settings for the /private/simplesamlphp-1.11.0/metadata/saml20-idp-remote.php file. Once this is done, you have to download the federation metadata file from the SimpleSAMLphp library and Upload the metadata to PingOne. The metadata uploaded will automatically populate all required fields. Make sure to upload your SHA256 certificate /private/simplesamlphp-1.11.0/cert/saml.crt. Hit Continue to Next Step. SAML metadata
  4. Next configure your field mapping. This is required by the simpleSAMLphp Authentication Drupal module. The value of the Application attribute field could be anything, it doesn't have to match to my example, but make sure you use the same attribute in Drupal.
    Field mapping
  5. Hit Save & Publish and you should be all set with the PingOne application.
  6. The last step would be Users. Create yourself a test user account so you can verify SSO works. Users

SimpleSAMLphp configuration

  • To configure /private/simplesamlphp-1.11.0/config/config.php see the SimpleSAMLphp configuration in ADFS and SimpleSAMLphp with Drupal.
  • `/private/simplesamlphp-1.11.0/config/authsources.php` looks a little bit different for the PingOne implementation. For example:
    $config = array(
      'admin' => array(
        'core:AdminPassword',
      ),
      'default-sp' => array(
       'saml:SP',
       'entityID'             => 'urn:drupal:pingone-saml',
       // For IDP URL see your PingOne application details.
       'idp'                  => 'https://pingone.com/idp/cd-000000000.minnuryunusov',
       'NameIDPolicy'         => null,
       'redirect.sign'        => true,
       // This should be FALSE.
       'assertion.encryption' => false,
       'sign.logout'          => true,
       // These are the certs from `/cert` directory.
       'privatekey'           => 'saml.pem',
       'certificate'          => 'saml.crt',
       // Defaults to SHA1 (http://www.w3.org/2000/09/xmldsig#rsa-sha1)
       'signature.algorithm'  => 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
      ),
    );
    
    

Drupal configuration

  • Follow the Basic setup from here (See Drupal Configuration section).
  • User info and syncing would look as follows:
    • Usually we set a username as the user's email address, if this is the case for you please try add the following: eduPersonPrincipalName
    • The unique identifier for the user: eduPersonPrincipalName
    • The user mail address: mail

Test SimpleSAMLphp and Drupal

  • Try to log in, using the following URL address to test:
    • https://dev-example.pantheon.io/saml_login
  • If you would  like to support login via SAML only, replace Login (user/login) path with /saml_login

If you have properly configured both SimpleSAMLphp and the PingOne application you should see a PingOne login screen when you login via https://dev-example.pantheon.io/saml_login. PingOne login screen

Troubleshoot

  • Make sure you have correct certificates and make sure you use correct ecnryption when you generate them.
  • See also the Troubleshoot section in ADFS and SimpleSAMLphp with Drupal.

Useful links

Oct 13 2015
Oct 13

These are instructions on how to connect to Microsoft SQL Server 2014 (SQL Server Express) from Drupal. In my case the SQL server was on a different machine and I had to make the server visible within the local network.

My Windows machine is running on Windows 7 Pro and Microsoft SQL Server Express 2014 (free version).

My development machine is MacBook Pro running OS X Yosemite (the latest version).

Configuring Microsoft SQL Server

First if you would like to connect to your MSSQL Server from a remote machine you have to enable network access to it. To do so:

Enable a network protocol.

  1. On the Start menu, choose All Programs, point to Microsoft SQL Server and then click SQL Server Configuration Manager.
  2. Expand SQL Server Network Configuration, and then click Protocols for InstanceName.
  3. In the list of protocols, right-click the protocol you want to enable, and then click Enable. The icon for the protocol will change to show that the protocol is enabled.
  4. To disable the protocol, follow the same steps, but choose Disable in step 3.

Configure a network protocol

  1. On the Start menu, right-click My Computer, and then choose Manage.
  2. In Computer Management, expand Services and Applications, expand SQL Server Configuration Manager, expand Server Network Configuration, expand Protocols for InstanceName, and then click the protocol you want to configure.
  3. Right-click the protocol you want to configure, and then choose Properties.
  4. In Properties, you can set the protocol-specific options.

You may or may not be able to ping your server. If you can ping then you can try connect to your server via PHP. If not try next step (configure Windows Firewall).

Configuring Windows Firewall

In this instruction I am completley disabling Windows Firewall and other third-party firewalls. Please note this is not recommended if your server can be accessed from outside of your network. As long as it is accessible in you local network only you should be fine.

  1. Open Windows Firewall. Start > Control Panel > Windows Firewall.
  2. In the left pane click Turn Windows Firewall On/Off.
  3. Click Turn off Windows Firewall (not recommended) under each network location that you want to stop trying to protect, and then click OK.

You could keep it enabled (recommended) and a rule that would only allow access to Microsoft SQL Server's port which is defaults to 1433.

Installing and configuring Microsoft SQL libraries for PHP

Generic solution

Micorsoft SQL Server libraries used to be included with PHP and appearantly this no longer the case. If you are using an Microsoft IIS server, use the native SQL client called SqlSrv. For Mac OS you will need to install the FreeTDS library.

  1. Install the Sysbase library for PHP (FreeTDS is provided with this)

    sudo apt-get install php5-sybase

  2. Configure the freetds.conf file to use version 8.0 (this is necessary to work with MSSQL 2014 (works with 2008 too), if you do not do this you may encounter odd behavior with encodding)

    sudo vim /etc/freetds/freetds.conf

    [your-server-name]
      host = [your-mssql-server-ip]
      port = 1433
      tds version = 8.0
      # This might help to fix encoding issues.
      client charset = UTF-8
    
    
  3. Restart Apache

Alternative solution

This is what I ended up using Vagrant and Docker.

Portion of my Dockerfile:

RUN add-apt-repository ppa:ondrej/php5 && \
  apt-get update && \
  DEBIAN_FRONTEND="noninteractive" apt-get install --yes \
      freetds-bin \
      php5-sybase

I won't cover instructions about Vagrant and Docker in this blog post, but you can find more information on their websites. Watch out, you might decide never use your current webserver like MAMP, XAMPP (just kidding). I was really impressed with this solution and I would highly recommend Vagrant and Docker to all developers.

Configuring Drupal

This section is really depends on how you're going to use MSSQL in your project. In my case I was running content migration using Migrate module.

  1. I added the following to my settings.php:
    $conf['mssql'] = array(
      'database' => '[mssql-db-name]',
      'username' => '[mssql-user]',
      'password' => '[mssql-pass]',
      # /etc/freedts/freedts.cfg connection name.
      'servername' => '[your-server-name]',
    );
    
    
  2. Next try connect to MSSQL from PHP (this is old way of doing but it worked for what I needed):

    function query_mssql($query) {
      static $conn = NULL;
    
      if ($conn == NULL) {
        $mssql = variable_get('mssql', NULL);
        $host = $mssql['servername'];
    
        $conn = mssql_connect(
          $mssql['servername'],
          $mssql['username'],
          $mssql['password'],
          TRUE
        );
        if (isset($mssql['database'])) {
          mssql_select_db($mssql['database'], $conn);
        }
      }
    
      $result = mssql_query($query, $conn);
      return $result;
    }
    
    

The example above works great with the Migrate module but it is recommended to use PDO Driver for Microsoft SQL Server in case if you would like to pull data from MSSQL Server to display it in your website. Download DBLIB.

As I mentioned the solution provided in this blog post worked just fine for what I needed, however consider also using Drupal 7 driver for SQL Server and SQL Azure, instructions for the module can be found here.

Troubleshoot

  • Check Firewall to see if it's enabled, make sure you allow access to MSSQL's port (defaults to 1433).
  • Make sure SQL Browser is running.
    1. On the Start menu, right-click My Computer, and then click Manage.
    2. In Computer Management, expand Services and Applications, and then click Services.
    3. In the list of services, double-click SQL Server Browser.
    4. In the SQL Server Browser Properties window, click Start or Stop.
    5. When the service starts or stops, click OK.
  • If SQL Server Browser doesn't have start/stop option and if it is greyed out.
    1. Make sure the Server Browser service is enabled; it is set to “Start Mode – disabled” by default. It is a security best practice to not run the SQL Server Browser service by default, as it reduces the attack surface area by eliminating the need to listen up on an UDP port. But if you keep it disabled you won't be able to access your server from a remote computer.
  • Make sure you specified correct IP address.
  • Make sure to restart SQL services every time you make changes.
  • Make sure you're using correct port number (defaults to 1433).

Useful links

  1. How to configure SQL Express 2012 to accept remote connections
  2. Installing the MSSQL Libraries for PHP on Linux
  3. How to: Enable Network Access in SQL Server Configuration Manager (SQL Server Express)
  4. Turn Windows Firewall on or off
  5. How to: Start and Stop the SQL Server Browser Service (SQL Server Express)
  6. Connecting Drupal running in Windows to MS SQL Server
Sep 25 2015
Sep 25

These are instructions on how to configure SimpleSAMLphp library and Drupal on Pantheon, the configuration settings may vary depending on the ADFS configuration.

Requirements

  1. Download SimpleSAMLphp version 1.11.0. The newer versions available but I haven't had a chance to try them.
  2. Drupal 7 site (latest version always recommended).
  3. simpleSAMLphp Authentication Drupal 7 module.
  4. Drupal 7 site running on Pantheon.

Install SimpleSAMLphp

  • Create a private directory in your document root: /private and move downloaded files and folders to /private/simplesamlphp-1.11.0

  • Create a symlink to your simplesamlphp-1.11.0 directory from /simplesaml to /private/simplesamlphp-1.11.0/www

$ ln -s ./private/simplesamlphp-1.11.0/www ./simplesaml
$ git add ./private/simplesamlphp-1.11.0
$ git add simplesaml
$ git commit -m "Add SimpleSAMLphp library. Add SimpleSAML symlink."
  • Generate certificates as needed and add them to your repository /private/simplesamlphp-1.11.0/cert

Important: Make sure you are using correct encryption. Microsoft announced that they wouldn't be accepting SHA1 certificates after 2016. Read more. Generate SHA256 certificates:

$ cd cert
$ openssl req -x509 -sha256 -nodes -days 3652 -newkey rsa:2048 -keyout saml.pem -out saml.crt

SimpleSAMLphp Configuration

  • Set up your /private/simplesamlphp-1.11.0/config/config.php file
// Put this at the top of the config.php file
if (!ini_get('session.save_handler')) {
  ini_set('session.save_handler', 'file');
}

$ps = json_decode($_SERVER['PRESSFLOW_SETTINGS'], TRUE);
$host = $_SERVER['HTTP_HOST'];
$drop_id = $ps['conf']['pantheon_binding'];
$db = $ps['databases']['default']['default'];

Set the following parameters in the config = array():

// Make sure you have SSL certificate enabled
// if you would like to use HTTPS protocol.
'baseurlpath'             => 'https://'. $host .'/simplesaml/',
'certdir'                 => 'cert/',
'loggingdir'              => 'log/',
'datadir'                 => 'data/',
'tempdir'                 => '/srv/bindings/'. $drop_id . '/tmp/simplesaml',

// Change admin password.
'auth.adminpassword'      => '[YOUR_PASSWORD]',
// Set this to TRUE
'admin.protectindexpage'  => true,
// Probably better to set this to TRUE
'admin.protectmetadata'   => false,
    
// Change this salt
'secretsalt'              => '[SALT_CHANGE_THIS]',
    
// Update contact information
'technicalcontact_name'   => 'Contact Name',
'technicalcontact_email'  => '[email protected]',
    
'enable.saml20-idp'       => true,
'session.cookie.secure'   => true,
    
'store.type' => 'sql',
'store.sql.dsn'           => 'mysql:host='
  . $db['host'] 
  . ';port='. $db['port'] 
  . ';dbname=' . $db['database'],
'store.sql.username'      => $db['username'],
'store.sql.password'      => $db['password'],
  • Now you should be able to login to your SimpleSAMLphp interface.

    • Visit http://dev-example.pantheon.io/simplesaml
    • Enter the password from the config.php value of the auth.adminpassword parameter.
    • Make sure Checking your PHP installation on the config page has no red flags. http://dev-example.pantheon.io/simplesaml/module.php/core/frontpage_config.php
    • Make sure SAML 2.0 IdP is green.
  • Next, configure /private/simplesamlphp-1.11.0/config/authsources.php file

$config = array(
  'admin' => array(
    'core:AdminPassword',
  ),
  'default-sp' => array(
   'saml:SP',

   // You can get this from ADFS Federation file
   // Contact your ADFS administrator
   // to obtain this information.
   'entityID'             => 'urn:drupal:adfs-saml',
   'idp'                  => 'http://example.org/adfs/services/trust',
   'NameIDPolicy'         => null,
   'redirect.sign'        => true,
   'assertion.encryption' => true,
   'sign.logout'          => true,

   // Generate using openssl, @see example above.
   // These are the certs from `/cert` directory.
   'privatekey'           => 'saml.pem',
   'certificate'          => 'saml.crt',
   // Defaults to SHA1 (http://www.w3.org/2000/09/xmldsig#rsa-sha1)
   'signature.algorithm'  => 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
  ),
);
  • Next you will need a Federation metadata file from the ADFS. Contact your ADFS administrator to generate the file.

  • After you obtain the federation metadata file use the XML to SimpleSAMLphp metadata converter to generate other config files. The converter is a part of the SimpleSAMLphp library and can be accessed through web: http://dev-example.pantheon.io/simplesaml/admin/metadata-converter.php

The result should look similar to the following:

saml20-sp-remote

Put the results of this section into /private/simplesamlphp-1.11.0/metadata/saml20-sp-remote.php

$metadata['http://example.org.org/adfs/services/trust'] = array (
  'entityid' => 'http://example.org/adfs/services/trust',
  'contacts' => 
  array (
    0 => 
    array (
      'contactType' => 'support',
    ),
  ),
  'metadata-set' => 'saml20-sp-remote',
  'AssertionConsumerService' => 
  array (
    0 => 
    array (
      'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
      'Location' => 'https://example.org/adfs/ls/',
      'index' => 0,
      'isDefault' => true,
    ),
    1 => 
    array (
      'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact',
      'Location' => 'https://example.org/adfs/ls/',
      'index' => 1,
    ),
    2 => 
    array (
      'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
      'Location' => 'https://example.org/adfs/ls/',
      'index' => 2,
    ),
  ),
  'SingleLogoutService' => 
  array (
    0 => 
    array (
      'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
      'Location' => 'https://example.org/adfs/ls/',
    ),
    1 => 
    array (
      'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
      'Location' => 'https://example.org/adfs/ls/',
    ),
  ),
  'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
  'keys' => 
  array (
    0 => 
    array (
      'encryption' => true,
      'signing' => false,
      'type' => 'X509Certificate',
      'X509Certificate' => .... certificate string ...',
    ),
    1 => 
    array (
      'encryption' => false,
      'signing' => true,
      'type' => 'X509Certificate',
      'X509Certificate' => '.... certificate string ...',
    ),
  ),
  'saml20.sign.assertion' => true,
);

saml20-idp-remote

Put the results of this section into /private/simplesamlphp-1.11.0/metadata/saml20-idp-remote.php

$metadata['http://example.org/adfs/services/trust'] = array (
  'entityid' => 'http://example.org/adfs/services/trust',
  'contacts' => 
  array (
    0 => 
    array (
      'contactType' => 'support',
    ),
  ),
  'metadata-set' => 'saml20-idp-remote',
  'SingleSignOnService' => 
  array (
    0 => 
    array (
      'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
      'Location' => 'https://example.org/adfs/ls/',
    ),
    1 => 
    array (
      'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
      'Location' => 'https://example.org/adfs/ls/',
    ),
  ),
  'SingleLogoutService' => 
  array (
    0 => 
    array (
      'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
      'Location' => 'https://example.org/adfs/ls/',
    ),
    1 => 
    array (
      'Binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
      'Location' => 'https://example.org/adfs/ls/',
    ),
  ),
  'ArtifactResolutionService' => 
  array (
  ),
  'keys' => 
  array (
    0 => 
    array (
      'encryption' => true,
      'signing' => false,
      'type' => 'X509Certificate',
      'X509Certificate' => '.... certificate string ...',
    ),
    1 => 
    array (
      'encryption' => false,
      'signing' => true,
      'type' => 'X509Certificate',
      'X509Certificate' => '.... certificate string ...',
    ),
  ),
);

  • Finally you will have to provide information to your ADFS administrator. The SimpleSAMLphp library can generate the federation metadata file for you. Here this the link:

http://dev-example.pantheon.io/simplesaml/module.php/saml/sp/metadata.php/default-sp?output=xhtml

The federation metadata XML file should look something like this:

<?xml version="1.0"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#" entityID="urn:drupal:adfs-saml">
  <md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:1.1:protocol urn:oasis:names:tc:SAML:2.0:protocol">
    <md:KeyDescriptor use="signing">
      <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
        <ds:X509Data>
          <ds:X509Certificate>.... certificate string .....</ds:X509Certificate>
        </ds:X509Data>
      </ds:KeyInfo>
    </md:KeyDescriptor>
    <md:KeyDescriptor use="encryption">
      <ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
        <ds:X509Data>
          <ds:X509Certificate> .... certificate string .....</ds:X509Certificate>
        </ds:X509Data>
      </ds:KeyInfo>
    </md:KeyDescriptor>
    <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://dev-example.pantheon.io/simplesaml/module.php/saml/sp/saml2-logout.php/default-sp"/>
    <md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" Location="https://dev-example.pantheon.io/simplesaml/module.php/saml/sp/saml2-logout.php/default-sp"/>
    <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://dev-example.pantheon.io/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp" index="0"/>
    <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:1.0:profiles:browser-post" Location="https://dev-example.pantheon.io/simplesaml/module.php/saml/sp/saml1-acs.php/default-sp" index="1"/>
    <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" Location="https://dev-example.pantheon.io/simplesaml/module.php/saml/sp/saml2-acs.php/default-sp" index="2"/>
    <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:1.0:profiles:artifact-01" Location="https://dev-example.pantheon.io/simplesaml/module.php/saml/sp/saml1-acs.php/default-sp/artifact" index="3"/>
  </md:SPSSODescriptor>
  <md:ContactPerson contactType="technical">
    <md:GivenName>Minnur</md:GivenName>
    <md:SurName>Yunusov</md:SurName>
    <md:EmailAddress>[email protected]</md:EmailAddress>
  </md:ContactPerson>
</md:EntityDescriptor>

Drupal Configuration

Basic setup

User info and syncing

  • Usually we set username as user's email address, if this is the case for you please try add the following:
    • http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress
  • Unique identifier for the user
    • http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn
  • User mail address
    • http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress
  • Hit Save configuration

ADFS Configuration

See Useful Links below. Some of those links contain information on how to configure the ADFS.

Test SimpleSAMLphp and Drupal

  • Try login, use the following URL address to test:
    • https://dev-example.pantheon.io/saml_login
  • If you would like to support login via SAML only I would recommend you to replace Login (user/login) path with /saml_login

Troubleshoot

  1. Login works but logout doesn't.
    • Make sure your certificates are generated using correct encryption.
    • Make sure your ADFS configuration is set to use correct encryption. In my case it was set to SHA256 and the certificates were generated using SHA1.
  2. If you're using SHA256 certificates make sure to specify encryption algorithm in config/authsources.php by adding signature.algorithm. See SAML 2.0 Options section.
  3. Get errors on login.
    • Make sure ADFS has correct claim rules. All fields should be in it's own Claim Rule.
  4. Make sure to restart ADFS service every time you make a change.
  5. If login used to work but stopped working after a year/month etc. Most likely the certificate expiration date was set to be a year/month. To solve this issue simply regenerate the certificate. See example above.

Useful links

Sep 18 2015
Sep 18

In this blog post I will try provide an example on how you could make your modules extendable within Drupal 7.

This is a very simple module that displays buttons with labels from your custom plugins on http://localhost/my-module/plugin page and with the configuration form here: http://localhost/admin/config/development/my-module.

Hopefully this is going to be a very useful tutorial for you and help you to make your modules extendable.

Download example module

Tutorial

First lets create a custom module with the following structure:

my_module
my_module/my_module.info
my_module/my_module.module
my_module/plugins/plugin.php
my_module/plugins/example.php

In the my_module.info file implement your custom hook.

IMPORTANT: make sure you clear Drupal cache after you add new plugin to .info files[].

name = My Module
description = Description of the module
core = 7.x
configure = admin/config/development/my-module

; This is required. You have to include all plugin files.
files[] = plugins/plugin.php
files[] = plugins/example.php

In the my_module.module file implement your custom hook.

/**
 * Implements hook_menu().
 */
function my_module_menu() {

  // My module configuration page.
  $items['admin/config/development/my-module'] = array(
    'title' => 'My Module',
    'description' => 'Description of the module',
    'page callback' => 'drupal_get_form',
    'page arguments' => array('my_module_configuration_form'),
    'access arguments' => array('administer site configuration'),
    'type' => MENU_NORMAL_ITEM,
  );

  // Path for the plugin integration page.
  // This page will display buttons from
  // all enabled plugins.
  $items['my-module/plugin'] = array(
    'title' => 'My Module Elements',
    'description' => 'Description of the module',
    'page callback' => 'drupal_get_form',
    'page arguments' => array('my_module_view_form'),
    'access callback' => TRUE,
    'type' => MENU_NORMAL_ITEM,
  );

  return $items;
}

/**
 * Implements hook_my_own_hook().
 */
function my_module_my_own_hook() {
  return array(
    // Machine name of the plugin.
    'example' => array(
      // Human readable string visible on the config page.
      'name' => t('Example plugin'),
      // PHP Class name that extends MyPluginBase class.
      'phpClassName' => 'Example',
    ),
  );
}

/**
 * View form.
 * Simple callback that implements buttons with labels
 * pulled from plugin `label()` methods.
 */
function my_module_view_form($form, &$form_state) {

  // Load all plugins.
  $plugins = my_module_load_plugins();
  foreach ($plugins as $name => $plugin) {

    // Display only enabled plugins.
    $enabled = variable_get(
      'my_module_' . $name . '_enabled',
      FALSE
    );

    if ($enabled) {
      // Get button label.
      $label = my_module_plugin_method(
        $plugin['phpClassName'],
        'label'
      );
      $form[$name] = array(
        '#type' => 'submit',
        '#value' => $label,
      );
    }

  }

  return $form;
}

/**
 * Configuration form.
 */
function my_module_configuration_form($form, &$form_state) {
  $form['settings'] = array(
    '#type' => 'vertical_tabs',
  );

  // Load all plugins.
  $plugins = my_module_load_plugins();
  foreach ($plugins as $name => $plugin) {

    $form['settings'][$name] = array(
      '#type' => 'fieldset',
      '#title' => $plugin['name'],
    );

    // Plugin status.
    $form['settings'][$name][$name . '_enabled'] = array(
      '#type' => 'checkbox',
      '#title' => t('Enable plugin'),
      '#default_value' => variable_get(
        'my_module_' . $name . '_enabled',
        FALSE
      ),
      '#description' => t("Description for the checkbox."),
    );

    // Check if plugin has configuraiton form.
    $configForm = my_module_plugin_method(
      $plugin['phpClassName'],
      'configForm',
      array($form_state)
    );
    if (is_array($configForm)) {
      $form['settings'][$name] += $configForm;
    }

  }

  $form['submit'] = array(
    '#type' => 'submit',
    '#value' => t('Save settings'),
  );

  return $form;
}

/**
 * Form validation handler.
 */
function my_module_configuration_form_validate($form, &$form_state) {
  $plugins = my_module_load_plugins();
  foreach ($plugins as $name => $plugin) {
    // Trigger plugin validation handler method.
    my_module_plugin_method(
      $plugin['phpClassName'],
      'validationHandler',
      array($form, $form_state)
    );
  }
}

/**
 * Form submit handler.
 */
function my_module_configuration_form_submit($form, &$form_state) {
  $plugins = my_module_load_plugins();
  foreach ($plugins as $name => $plugin) {
    // Save Enable options.
    variable_get(
      'my_module_' . $name . '_enabled',
      $form_state['values'][$name . '_enabled']
    );
    // Trigger plugin submit handler method.
    my_module_plugin_method(
      $plugin['phpClassName'],
      'submitHandler',
      array($form, $form_state)
    );
  }
  drupal_set_message(t('The configuration options have been saved.'));
}

/**
 * Helper function to invoke your custom hook.
 *
 * @return array List of available plugins.
 */
function my_module_load_plugins() {

  $plugins = array();
  // Invoke `my_own_hook` implemented in all modules.
  $plugin_hooks = module_invoke_all('my_own_hook');

  foreach ($plugin_hooks as $name => $plugin) {

    // @see http://php.net/manual/en/class.reflectionclass.php
    $reflection = new ReflectionClass($plugin['phpClassName']);

    // Now make sure the plugin class is extended from
    // your abstract `MyPluginBase` class.
    if ($reflection->isSubclassOf('MyPluginBase')) {
      $plugins[$name] = $plugin;
    }

  }

  return $plugins;
}

/**
 * Helper function to call phpClass method.
 *
 * @return mixed Result of the class method.
 */
function my_module_plugin_method($class, $method, $args = array()) {

  $reflection = new ReflectionClass($class);
  $method = $reflection->getMethod($method);
  $pluginClass = new $class();
  return $method->invokeArgs($pluginClass, $args);

}

The plugin.php is going to be an abstract class for the plugin.

abstract class MyPluginBase {

  /**
   * Button label.
   */
  abstract public function label();

  /**
   * Configuration form.
   * Drupal form API elements.
   *
   * @return array.
   */
  abstract public function configForm(&$form_state);

  /**
   * Drupal form validation handler.
   */
  abstract public function validationHandler($form, &$form_state);

  /**
   * Drupal form submit handler.
   */
  abstract public function submitHandler($form, &$form_state);

}

The example.php is your first example plugin that uses MyPluginBase class.

/**
 * Example plugin.
 */
class Example extends MyPluginBase {

  /**
   * {@inheritdoc}
   */
  public function label() {
    return t('Example');
  }

  /**
   * {@inheritdoc}
   */
  public function configForm(&$form_state) {

    $form['example_setting'] = array(
      '#title' => t('Example variable'),
      '#type' => 'textfield',
      '#default_value' => variable_get('my_module_example_setting', ''),
      '#description' => t('Description of the configraiont option.'),
    );

    return $form;

  }

  /**
   * {@inheritdoc}
   */
  public function validationHandler($form, &$form_state) {
    if (empty($form_state['values']['example_setting'])) {
      form_set_error('example_setting', t('Validation error'));
    }
  }

  /**
   * {@inheritdoc}
   */
  public function submitHandler($form, &$form_state) {
    variable_set(
      'my_module_example_setting',
      $form_state['values']['example_setting']
    );
  }

}

Download example module

Sep 08 2015
Sep 08

I always wanted some kind of tool in Drupal that would allow me to upload files from third-party services.

There are quite a few modules that would allow me to upload files but none of them worked well for me. Either they asked me to put the URL or they would just store URL's to remote files.

Because of lack of such a module on Drupal.org I came up with the idea of creating a module that would combine several services and have ability to download files and use them in Drupal specified fields.

After spending several days I was able to create a working prototype and eventually released a module called File Chooser Field.

See on Drupal.org

The module extends File and Image fields by adding ability to upload files from third-party services such as Dropbox, Box, OneDrive, Google Drive and Instagram. The module's built-in plugin API allows developers extend functionality of the module by integrating other service providers.

The module respects Drupal field settings such as file size limit, extensions, cardinality.

You have to note that this module downloads remote files and stores them in the Drupal field.

Requirements

No other dependencies all required modules are in core.

Quick Start

  1. Enable the module.
  2. Configure File Chooser Field settings (open admin/config/media/file-chooser-field).
  3. Edit your File and Image fields and enable "Enable third party file uploads" checkbox.
  4. Configure permissions.

Plugin API

  1. Implement hook_file_chooser_field_plugins().
  2. Create a new class by extending FileChooserFieldPlugin class (see plugins/PluginBase.php).
  3. Enable the plugin (open admin/config/media/file-chooser-field).

Roadmap

  • Drupal 8 version of the module.
  • More third-party service plugin integrations.

Download the module and see file_chooser_field.api.php file for all available hooks.

Feb 24 2014
Feb 24

When you're building a Drupal website it is important to organize your code. Each developer or company has its own way of organizing modules structure. In this blog post I will show the way I organize my code and directory structure.

Directories.

  • sites/all/modules/contrib - Contains modules downloaded from Drupal.org, Please note, you should never modify any modules in this directory, except for cases when you need to apply patches.
  • sites/all/modules/custom - Contains custom modules.
  • sites/all/modules/features - If you use Features module you create a separate directory, however, you don't have to do this. The "feature" generated with the Features module doesn't look too much different from the module you would write in custom directory. You could write all you code with in the feature in .module file.
  • sites/all/themes/contrib - Contains themes downloaded form the internet.
  • sites/all/themes/custom - Contains custom themes.
  • sites/all/libraries - Contains any 3rd party libraries like wysiwyg editors or javascript plugins.

Organizing settings.php.

In case if you work in a team or your work with contractors you don't want to share credentials to your production environments. In order to prevent this I usually store common configuration settings in setting.php and all the local changes, like DB username/password I keep in the GIT/SVN ignored file called secret.settings.php and I include this file into settings.php.

My sites/default directory structure:

  • settings.php - Default settings.php file, the file includes secret.settings.php and contains all default Drupal settings, I don't store and DB connection information there.
  • example.secret.settings.php - Example file, other developers would need to copy file and rename it to secret.settings.php file and update connection details.
  • secret.settings.php - Contains local settings, like DB connection, ApacheSolr configuration settings, and performance settings. This file is ignored by version control.

Custom module.

My modules usually have the following directory structure:

  • mymodule.module - Main module file, contains all the Drupal hook implementations.
  • mymodule.admin.inc - Administrative menu callbacks, add/edit forms.
  • mymodule.pages.inc - Public menu callbacks, accessible by non-editor and non-admin users.
  • mymodule.inc - Helper functions (e.g call classes from includes/ directory)
  • mymodule.info - Module information file. https://drupal.org/node/542202
  • mymodule.install - Module installer, contains schema information, code cleanup (e.g. remove custom variables in hook_uninstall)
  • includes/ - Contains custom classes, views handlers, etc.
  • templates/ - Theme related files (*.tpl.php files, sometimes I store preprocess files here)
  • plugins/ - I use this directory for custom Ctools plugins
  • css/ - CSS styles
  • images/ - Images and sprite graphics
  • js/ - Javascript helper files

Code.

*.info file:

Drupal uses .info files to store metadata about themes and modules. Important information about contents of the file https://drupal.org/node/542202
If you have custom classes, please include all the files using "files" property (example: files[] = tests/example.test). This lets Drupal load your classes only when needed.

Drupal 7 has very useful feature that allows to load CSS/JS files form .info file:

stylesheets[all][] = mymodule_styles.css
scripts[] = mymodule.js

*.module file

Contains all hook implementations.

In order to make code more well organized and load hooks only when needed I use "hook_hook_info()".
See https://api.drupal.org/api/drupal/modules%21system%21system.api.php/func...

Example.

The following implementation of hook_hook_info tells Drupal that implementations of hook_node_insert, hook_node_update and hook_node_delete are located in mymodule.node.inc file.

function mymodule_hook_info() {
  $hooks['node_insert'] = array(
    'group' => 'node',
  );
  $hooks['node_update'] = array(
    'group' => 'node',
  );
  $hooks['node_delete'] = array(
    'group' => 'node',
  );
  return $hooks;
}

By doing this you will reduce the file size of your main module file and load hook_node_* hooks only when needed.

Drupal theme.

When I create a custom theme or theme based on other themes I use the following structure:

  • themes/custom/mytheme/
    • mytheme.info - Theme info file
    • template.php - Theme preprocesses and other helper function
    • favicon.ico - Favicon
    • logo.png - I usually place the main logo out of the images directory
    • images/ - Contains all theme related images
    • css/ - Compiled CSS files
    • less/sass - LESS or SASS files
    • js/ - Various JS helper functions, plugins
    • templates/ - I prefer to create separate folder per template types so it's easier to maintain the theme and locate the file you need to modify
      • views/ - Views related templates, it could also contain subfolder like "views/myview/"
      • nodes/ - Node templates
      • pages/ - Page templates files, page--front.tpl.php, page.tpl.php, etc.
      • blocks/ - Block templates
      • regions/ - Region templates
      • html.tpl.php - Main html wrapper

I would love to hear new ideas and techniques you use in your development process.

Feb 21 2012
Feb 21

A few months ago I had to develop a Drupal 7 website that was supported by advertising.
After some research I realized that there were no current advertising modules for Drupal 7.
The Advertisement module is the de facto advertisement module for Drupal but a Drupal 7
version wasn't even under development.
So I decided to develop a new contrib module for featuring advertisements on Drupal 7 that was easy-to-use and provided
some some basic statistics. I called it SimpleAdsSimpleAds: Drupal 7 advertisement module

SimpleAds provides a way to manage advertisements on a Drupal 7 website. It displays graphical, text and flash ads in configurable blocks. It records and reports useful statistics. It is easy to configure and does not depend on other contrib modules.

Features

  • Customizable groups for different ad types
  • Automatically generates ad blocks for different ad types
  • Many configuration options for individual ad blocks including ad size and number of ads to display in a block
  • Convenient dashboard for quick review of active ads
  • User-friendly ad scheduler makes it easy to schedule ad activation and expiration. (Enter Activation or Expiration Date by typing Now, +1 week, +1 year, etc..)
  • Capture useful statistics including ad impressions, clicks for 1 hours, one day, one week, one month, 3 months, 6 months, 1 year, and all time.
  • Option to count clicks/impressions only for certain user roles
  • Generates live ad statistics report
  • Ad blocks can be themed
  • Dynamic Ad rotation (configurable on block configuration screen)
  • Integration with WYSIWYG module (supports TinyMCE, CKEditor, FCKEditor). Plugin allows to inject Ads into content.
  • Integration with Nodequeue module. Display manually ordered ads in blocks.
  • Integration with Domain Access module.
  • Integration with Views.
  • Text Ads allows to advertise any text. If you would like to advertise videos from YouTube, Vimeo, Blip.TV or other sites, simply install Video Filter module and enable WYSIWYG plugin.
  • Support Flash Ads. Upload SWF file and it will automatically show up in your block.

Configuration

SimpleAds: Create new ad

Image ads

Themable template: simpleads_img_element.tpl.php

Flash ads

Themable template: simpleads_flash_element.tpl.php

Design your flash SWF with the clickTAG standard. Do NOT place a URL in the getURL of your SWF or it will not work correctly in all browsers and you will not get any click-statistics in the SimpleAds module in Drupal. The destination URL should not be located in an SWF's code.

Text/HTML ads

Themable template: simpleads_text_element.tpl.php

This ad type allows to advertise embedded content (via Video module + WYSIWYG plugin), also if you'd like to display plain linked text ads, select Text format Plain text

SimpleAds: Plain text ads

SimpleAds: Video ads

Integration with Views

By default the SimpleAds module comes with one example block. In order to see it you will need to enable
"SimpleAds Block Example" in admin/structure/views.

SimpleAds: Enable default view

Place an ad into multiple blocks

By default Ad Group field supports only 1 value and this is very easy to fix to support multiple values. In order to do that you will need to modify Simple Ad content type here admin/structure/types/manage/simpleads/fields. Click Edit Ad Group field, then change Number of values from 1 to Unlimited.

Blocks

SimpleAds: block configuration screen

The SimpleAds support dynamic random ads rotation. To enable this feature please configure one of you SimpleAds blocks (see admin/structure/block). By default there are two blocks "Advertisement Block: Sidebar Ads" and "Advertisement Block: Content Ads".

Number of ads to display - set to 2 or more.

Ads order - set the order.

Ads auto rotation type - set rotation type and hit "Save".

New Ad Groups/Ad Blocks

To create new Ad blocks you will need to create a new taxonomy term in the Ad Groups vocabulary.

Statistics

SimpleAds: Statistics page

Statistics page: admin/content/simpleads

SimpleAds module allows to control clicks and impressions. For example you may set permission to count anonymous clicks/impressions (restrict/allow clicks/impressions counter for certain user roles).

Also to simplify editing process I decided to redirect editors/admins to actual node instead of ads destination. In order to check redirection you will need to be anonymous user or user that doesn't have permission to edit ads.

SimpleAds: Permission options

Roadmap

The SimpleAds module is currently being used on 434 websites according to Drupal.org. In the two months since first releasing the module in December 2011 I have released seven updates to date.

I am using this module on active websites and continue to develop it. If you find any bugs or have suggestions for improvement please send me your feedback via SimpleAds.

About Drupal Sun

Drupal Sun is an Evolving Web project. It allows you to:

  • Do full-text search on all the articles in Drupal Planet (thanks to Apache Solr)
  • Facet based on tags, author, or feed
  • Flip through articles quickly (with j/k or arrow keys) to find what you're interested in
  • View the entire article text inline, or in the context of the site where it was created

See the blog post at Evolving Web

Evolving Web