You are here

amazons3.module in AmazonS3 7.2

Same filename and directory in other branches
  1. 7 amazons3.module

Hook implementations for the AmazonS3 module.

File

amazons3.module
View source
<?php

use Aws\Common\Credentials\Credentials;
use Drupal\amazons3\Exception\S3ConnectValidationException;
use Drupal\amazons3\S3Client;
use Drupal\amazons3\S3Url;
use Drupal\amazons3\StreamWrapperConfiguration;

/**
 * @file
 * Hook implementations for the AmazonS3 module.
 */

/**
 * Implements hook_stream_wrappers().
 *
 * Create a stream wrapper for S3.
 */
function amazons3_stream_wrappers() {

  // This hook is called before hook_init(), so we have to manually register
  // the autoloader. We also need to handle module upgrades where
  // composer_manager might not be enabled yet.
  if (!module_exists('composer_manager')) {
    return array();
  }

  // If the module has been enabled, but the user didn't update composer
  // libraries, prevent failing entirely.
  try {
    composer_manager_register_autoloader();
  } catch (\RuntimeException $e) {
    watchdog('amazons3', 'The Composer autoloader could not be registered. Run drush composer-rebuild and drush composer-manager update to update your vendor directory.');
    watchdog_exception('amazons3', $e);
    return array();
  }
  if (!class_exists('Drupal\\amazons3\\StreamWrapper')) {
    watchdog('amazons3', 'The AmazonS3 StreamWrapper class is missing. Make sure all module updates have run. Otherwise, run drush composer-rebuild and drush composer-manager update to update your vendor directory.');
    return array();
  }

  // If the module isn't configured yet, don't register the stream wrapper.
  try {
    StreamWrapperConfiguration::fromDrupalVariables();
  } catch (\InvalidArgumentException $e) {
    if (current_path() == 'admin/config/media/file-system') {
      drupal_set_message(t('The AmazonS3 module <a href="@s3-configure">needs to be configured</a> before setting it as a download method.', array(
        '@s3-configure' => url('admin/config/media/amazons3', array(
          'query' => array(
            'destination' => current_path(),
          ),
        )),
      )));
    }
    return array();
  }

  // getimagesize() calls require the stream to be seekable.
  stream_context_set_default([
    's3' => [
      'seekable' => TRUE,
    ],
  ]);
  return array(
    's3' => array(
      'name' => 'Amazon S3',
      'class' => 'Drupal\\amazons3\\StreamWrapper',
      'description' => t('Amazon Simple Storage Service'),
    ),
  );
}

/**
 * Implements hook_menu().
 */
function amazons3_menu() {
  $items = array();
  $items['admin/config/media/amazons3'] = array(
    'title' => 'Amazon S3',
    'description' => 'Configure S3 credentials and settings',
    'page callback' => 'drupal_get_form',
    'page arguments' => array(
      'amazons3_admin',
    ),
    'access arguments' => array(
      'administer amazons3',
    ),
    'file' => 'amazons3.admin.inc',
  );

  // hook_menu is called after this module is enabled, but before Composer
  // dependencies are enabled. This menu callback string should always match
  // \Drupal\amazons3\StreamWrapper::stylesCallback.
  $items['amazons3/image-derivative'] = array(
    'title' => 'Image style delivery callback',
    'description' => 'Callback to generate an image derivative, upload it to S3, and redirect to the S3 URL',
    'page callback' => 'amazons3_image_deliver',
    'access arguments' => array(
      'access content',
    ),
    'type' => MENU_CALLBACK,
  );
  return $items;
}

/**
 * Image delivery callback that uploads a derivative to S3.
 *
 * @param ...
 *   The path components of the source image.
 */
function amazons3_image_deliver() {
  $args = func_get_args();
  if (count($args) < 4) {
    return MENU_NOT_FOUND;
  }
  $bucket = $args[0];

  // Pop off the bucket and the 'styles' constant in the URL.
  array_shift($args);
  array_shift($args);
  $style_name = $args[0];

  // If the image style doesn't exist, we can return early.
  if (!($style = image_style_load($style_name))) {
    return MENU_NOT_FOUND;
  }

  // Pop off the style name; the rest is our key to the original image.
  array_shift($args);
  $path = $args;
  $key = implode('/', $path);
  $source = new S3Url($bucket, $key);
  $destination_s3 = $source
    ->getImageStyleUrl($style_name);

  // Check that the image style token is valid.
  if (!variable_get('image_allow_insecure_derivatives', FALSE) || strpos($destination_s3
    ->getKey(), 'styles/') === 0) {
    $valid = isset($_GET[IMAGE_DERIVATIVE_TOKEN]) && $_GET[IMAGE_DERIVATIVE_TOKEN] === image_style_path_token($style['name'], (string) $source);
    if (!$valid) {
      return MENU_ACCESS_DENIED;
    }
  }
  if (!file_exists($destination_s3)) {

    // If there is no source image we can 404 early.
    if (!file_exists($source)) {
      return MENU_NOT_FOUND;
    }
    $lock_name = 'amazons3_image_style_deliver:' . drupal_hash_base64($destination_s3
      ->getKey());
    $destination_temp = 'temporary://amazons3/' . $destination_s3
      ->getKey();

    // Prevent cache stampedes.
    if (!lock_acquire($lock_name)) {
      _amazons3_image_wait_transfer($destination_temp);
    }

    // If the temporary file exists, we assume another thread has generated it
    // and we can transfer it directly.
    if (!file_exists($destination_temp)) {
      $image = _amazons3_generate_image_style($source, $style, $destination_temp, $destination_s3);
    }
    else {
      $image = amazons3_image_load($destination_temp);
    }
    lock_release($lock_name);

    // Transfer the image to the client from our temporary directory.
    file_transfer($image->source, array(
      'Content-Type' => $image->info['mime_type'],
      'Content-Length' => $image->info['file_size'],
    ));
  }

  // If the file exists on S3, send a permanent redirect.

  /** @var \Drupal\amazons3\StreamWrapper $wrapper */
  $wrapper = file_stream_wrapper_get_instance_by_uri($destination_s3);
  drupal_goto($wrapper
    ->getExternalUrl(), array(), 301);
}

/**
 * Generate an image style derivative and upload it to S3.
 *
 * @param S3Url $source
 *   The URL of the source image stored in S3.
 * @param string $style
 *   The name of the image style to generate the derivative for.
 * @param string $destination_temp
 *   The temporary:// path to save the generated image in.
 * @param S3Url $destination_s3
 *
 * @throws \Exception
 *   Thrown if image_style_create_derivative() failed.
 *
 * @return \stdClass
 *   The generated image object.
 *
 */
function _amazons3_generate_image_style(S3Url $source, $style, $destination_temp, S3Url $destination_s3) {

  // Before we create the file, we need to see if a row exists in
  // {files_managed}. This can happen when a temporary directory is cleared
  // by a server reboot or manually by a system administrator.
  $q = new EntityFieldQuery();
  $q
    ->entityCondition('entity_type', 'file')
    ->propertyCondition('uri', $destination_temp);
  $results = $q
    ->execute();
  if (!empty($results['file'])) {
    $file = reset($results['file']);
    $file = file_load($file->fid);
    file_delete($file);
  }

  // Generate the derivative.
  if (!image_style_create_derivative($style, $source, $destination_temp)) {

    // Something went horribly wrong, but all we have is a FALSE return. Throw
    // an exception with something useful.
    throw new \Exception('Amazon S3 was unable to create an image style derivative. Check the temporary directory configuration and permissions.');
  }

  // We need to manage our temporary file so it is cleaned by system_cron().
  $file = amazons3_file_create_object($destination_temp);
  file_save($file);

  // Register a shutdown function to upload the image to S3.
  $image = amazons3_image_load($destination_temp);
  register_shutdown_function(function () use ($image, $destination_s3) {

    // We have to call both of these to actually flush the image.
    ob_end_flush();
    flush();

    // file_unmanaged_copy() will not create any nested directories if
    // needed.
    $directory = drupal_dirname($destination_s3);
    if (!file_prepare_directory($directory, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS)) {
      watchdog('amazons3', 'Failed to create style directory: %directory', array(
        '%directory' => $directory,
      ), WATCHDOG_ERROR);
    }
    file_unmanaged_copy($image->source, $destination_s3);
  });
  return $image;
}

/**
 * Create a file object.
 *
 * @param string $uri
 *   The URI of the object to create.
 *
 * @return \stdClass
 *   A file object suitable to use with file_save().
 */
function amazons3_file_create_object($uri) {
  $file = new stdClass();
  $file->fid = NULL;
  $file->uri = $uri;
  $file->filename = drupal_basename($uri);
  $file->filemime = file_get_mimetype($file->uri);
  $file->uid = 0;
  $file->status = 0;
  return $file;
}

/**
 * Wait for an image to appear in a directory, and transfer it when it appears.
 *
 * @param string $uri
 *   The image URI to transfer.
 *
 * @return bool
 *   FALSE if the image was not transferred.
 */
function _amazons3_image_wait_transfer($uri) {

  // Another process is trying to create the file on S3. S3 uploads can
  // still be slow, so we wait for the temporary file to exist and serve
  // that.
  $tries = 0;
  while ($tries < 4 && !file_exists($uri)) {
    usleep(500000);
    $tries++;
  }

  // If the file doesn't exist, it either means we had a stale lock, or the
  // other process died and couldn't create the image style. In that case,
  // we fall through and try to create the derivative without acquiring the
  // lock.
  if (file_exists($uri)) {
    $image = amazons3_image_load($uri);
    file_transfer($uri, array(
      'Content-Type' => $image->info['mime_type'],
      'Content-Length' => $image->info['file_size'],
    ));
  }
  return FALSE;
}

/**
 * Load an image, exiting if it could not be loaded.
 *
 * @param string $uri
 *   The image URI to load.
 *
 * @return \stdClass
 *   The loaded image.
 */
function amazons3_image_load($uri) {
  $image = image_load($uri);
  if (!$image) {
    watchdog('amazons3', 'Unable to generate the derived image located at %path.', array(
      '%path' => $uri,
    ));
    drupal_add_http_header('Status', '500 Internal Server Error');
    drupal_add_http_header('Content-Type', 'text/html; charset=utf-8');
    print t('Error generating image.');
    drupal_exit();
  }
  return $image;
}

/**
 * Implements hook_permission().
 */
function amazons3_permission() {
  return array(
    'administer amazons3' => array(
      'title' => t('Administer AmazonS3'),
    ),
  );
}

/**
 * Implements hook_flush_caches().
 */
function amazons3_flush_caches() {
  return array(
    'cache_amazons3_metadata',
  );
}

/**
 * Implements hook_field_info_alter().
 */
function amazons3_field_info_alter(&$info) {
  foreach (amazons3_file_like_field() as $type) {

    // Use the default bucket as specified in the module configuration.
    if (isset($info[$type])) {
      $info[$type]['settings']['amazons3_bucket'] = variable_get('amazons3_bucket', '');
    }
  }
}

/**
 * Implements hook_image_style_path_alter().
 *
 * When we are using S3, we need to rewrite image style URLs to route through
 * our own paths.
 */
function amazons3_image_style_path_alter(&$result, $style_name, $uri) {
  $scheme = file_uri_scheme($uri);
  if ($scheme != 's3') {
    return;
  }
  $s3url = S3Url::factory($uri);
  $result = $s3url
    ->getImageStyleUrl($style_name);
}

/**
 * Return an array of field types that are like a file field.
 *
 * If a field type is calling file_* hooks to create it's field, it likely
 * belongs here.
 *
 * @return array
 *   An array of field types.
 */
function amazons3_file_like_field() {
  return array(
    'file',
    'image',
  );
}

/**
 * Implements hook_field_widget_form_alter().
 *
 * Override file fields to use our destination function to determine the
 * upload location for a file.
 */
function amazons3_field_widget_form_alter(&$element, &$form_state, $context) {
  $field = $context['field'];
  $instance = $context['instance'];
  if (in_array($field['type'], amazons3_file_like_field())) {
    if (field_behaviors_widget('multiple values', $instance) == FIELD_BEHAVIOR_DEFAULT) {
      $delta = $context['delta'];
      $element[$delta]['#upload_location'] = amazons3_field_widget_uri($field, $instance);
    }
    else {

      // In this case this alter hook is only called once for all items in the
      // widget.
      foreach (element_children($element) as $delta) {
        $element[$delta]['#upload_location'] = amazons3_field_widget_uri($field, $instance);
      }
    }
  }
}

/**
 * Return the destination URI for a file field.
 *
 * @param array $field
 *   A field array.
 * @param array $instance
 *   A field instance array.
 * @param array $data
 *   (optional) An array of token objects to pass to token_replace().
 *
 * @see file_field_widget_uri()
 * @see token_replace()
 *
 * @return string
 *   A file directory URI with tokens replaced.
 */
function amazons3_field_widget_uri(array $field, array $instance, array $data = array()) {
  $uri_scheme = $field['settings']['uri_scheme'];
  $file_directory = isset($instance['settings']['file_directory']) ? $instance['settings']['file_directory'] : NULL;
  $bucket = isset($field['settings']['amazons3_bucket']) ? $field['settings']['amazons3_bucket'] : NULL;
  return amazons3_upload_location($uri_scheme, $bucket, $file_directory, $data);
}

/**
 * Return a URI for use in an #upload_location or similar form element.
 *
 * @param string $uri_scheme
 *   The scheme to use for the URI.
 * @param string $bucket
 *   (optional) bucket, if the URI is an s3 URI.
 * @param string $file_directory
 *   (optional) File directory for the URI.
 * @param array $data
 *   (optional) Array of data to use when replacing tokens.
 *
 * @return string
 *   A fully-qualified string URI.
 */
function amazons3_upload_location($uri_scheme, $bucket = NULL, $file_directory = NULL, array $data = array()) {
  if ($uri_scheme == 's3') {
    $destination = $bucket;

    // If no bucket is specified, but this is an S3 URI, use the default bucket.
    if (empty($destination)) {
      $config = StreamWrapperConfiguration::fromDrupalVariables();
      $destination = $config
        ->getBucket();
    }
    if (!empty($file_directory)) {
      $destination .= '/' . trim($file_directory, '/');
    }
  }
  else {
    $destination = trim($file_directory, '/');
  }

  // Replace tokens.
  $destination = token_replace($destination, $data);
  return $uri_scheme . '://' . $destination;
}

/**
 * Implements hook_form_FORM_ID_alter().
 */
function amazons3_form_field_ui_field_settings_form_alter(&$form, &$form_state, $form_id) {
  $type = $form['field']['type']['#value'];
  _amazons3_field_configuration($form, $type);
}

/**
 * Implements hook_form_FORM_ID_alter().
 *
 * Add bucket configuration to each file field form.
 */
function amazons3_form_field_ui_field_edit_form_alter(&$form, &$form_state, $form_id) {
  $type = $form['#field']['type'];
  _amazons3_field_configuration($form, $type);
}

/**
 * Implements hook_file_entity_upload_destination_uri_alter().
 */
function amazons3_file_entity_upload_destination_uri_alter(&$result, array $params = array(), array $data = array()) {
  if ($params['uri_scheme'] == 's3') {
    try {
      $url = S3Url::factory($result);
      $bucket = $url
        ->getBucket();
      $s3 = S3Client::factory(array(), $bucket);
      S3Client::validateBucketExists($bucket, $s3, new \Drupal\amazons3\Cache());
    } catch (\InvalidArgumentException $e) {

      // We couldn't parse the URL, so check to see if it is bare.
      if ($result == 's3://') {
        $config = StreamWrapperConfiguration::fromDrupalVariables();
        $bucket = $config
          ->getBucket();
      }
      else {
        throw $e;
      }
    } catch (S3ConnectValidationException $e) {
      if (!empty($params['field']) && ($field = field_info_field($params['field']))) {
        $bucket = $field['settings']['amazons3_bucket'];
      }
      else {
        $config = StreamWrapperConfiguration::fromDrupalVariables();
        $bucket = $config
          ->getBucket();
      }
    }
    $result = amazons3_upload_location('s3', $bucket, $params['file_directory'], $data);
  }
}

/**
 * Implements hook_file_stream_wrapper_uri_normalize_alter().
 */
function amazons3_file_stream_wrapper_uri_normalize_alter(&$uri, $scheme, $target) {
  if ($scheme == 's3') {
    try {

      // If this try passes, $uri is a fully-formed s3:// URI with a bucket.
      $url = S3Url::factory($uri);

      // Validate that the bucket exists. Sometimes we might be passed in URIs
      // without a bucket, like s3://image.jpg. If image.jpg is not a bucket, we
      // assume that image.jpg is supposed to be created in the default bucket.
      $bucket = $url
        ->getBucket();
      $s3 = S3Client::factory(array(), $bucket);
      S3Client::validateBucketExists($bucket, $s3, new \Drupal\amazons3\Cache());
    } catch (\InvalidArgumentException $e) {

      // Catch if S3Url::factory() can not parse $uri. That happens if we are
      // passed in a bare URI like s3://. Fall back to the default bucket.
      $uri = amazons3_uri_add_bucket($target);
    } catch (S3ConnectValidationException $e) {

      // Catch if a bucket does not exist or is invalid.
      $uri = amazons3_uri_add_bucket($target);
    }
  }
}

/**
 * Add the default bucket and return a string URL.
 *
 * @param string $target
 *   The file path to return the URL for.
 *
 * @return string
 *   A fully-qualified s3:// URL.
 */
function amazons3_uri_add_bucket($target) {
  $config = StreamWrapperConfiguration::fromDrupalVariables();
  $url = new S3Url($config
    ->getBucket(), $target);
  return (string) $url;
}

/**
 * Add S3 configuration to file field settings forms.
 *
 * @param array &$form
 *   The form to alter.
 * @param string $type
 *   The field type being modified.
 */
function _amazons3_field_configuration(array &$form, $type) {
  foreach (amazons3_file_like_field() as $types) {
    if ($type == $types) {
      $settings =& $form['field']['settings'];
      $bucket_setting = isset($form['#field']['settings']['amazons3_bucket']) ? $form['#field']['settings']['amazons3_bucket'] : '';
      $region_setting = isset($form['#field']['settings']['amazons3_region']) ? $form['#field']['settings']['amazons3_region'] : '';
      $settings['uri_scheme']['#weight'] = 50;
      $settings['amazons3_bucket'] = array(
        '#type' => 'textfield',
        '#title' => t('Amazon S3 bucket'),
        '#description' => t('Leave blank to use the site-wide default bucket <a href="@config">currently set to %bucket</a>.', array(
          '@config' => url('admin/config/media/amazons3'),
          '%bucket' => variable_get('amazons3_bucket', ''),
        )),
        '#states' => array(
          'visible' => array(
            ':input[name="field[settings][uri_scheme]"]' => array(
              'value' => 's3',
            ),
          ),
        ),
        '#default_value' => $bucket_setting,
        '#element_validate' => array(
          'amazons3_form_bucket_validate',
        ),
        '#weight' => 51,
      );
      $client = \Aws\S3\S3Client::factory();
      $settings['amazons3_region'] = array(
        '#type' => 'select',
        '#title' => t('Amazon S3 region'),
        '#description' => t('Use <em>default region</em> to use the site-wide default region <a href="@config">currently set to %region</a>.', array(
          '@config' => url('admin/config/media/amazons3'),
          '%region' => variable_get('amazons3_region', ''),
        )),
        '#states' => array(
          'visible' => array(
            ':input[name="field[settings][uri_scheme]"]' => array(
              'value' => 's3',
            ),
          ),
        ),
        '#default_value' => $region_setting,
        '#options' => array(
          0 => t('- default region -'),
        ) + array_combine(array_keys($client
          ->getRegions()), array_keys($client
          ->getRegions())),
        '#weight' => 51,
      );
    }
  }
}

/**
 * Element validate callback to validate a bucket name.
 *
 * @param array &$element
 *   The element to validate.
 * @param array &$form_state
 *   The current state of the form.
 * @param array $form
 *   The current form.
 */
function amazons3_form_bucket_validate(array &$element, array &$form_state, array $form) {
  $bucket = $element['#value'];
  if (empty($bucket)) {
    return;
  }
  if (!isset($form_state['values']['amazons3_hostname'])) {
    $hostname = variable_get('amazons3_hostname');
  }
  else {
    $hostname = $form_state['values']['amazons3_hostname'];
  }

  // Inject our credentials for testing.
  $config = array();
  if (isset($form_state['values']['amazons3_key']) && isset($form_state['values']['amazons3_secret'])) {
    $config['credentials'] = new Credentials($form_state['values']['amazons3_key'], $form_state['values']['amazons3_secret']);
  }
  if (isset($form_state['values']['amazons3_region'])) {
    $config['region'] = $form_state['values']['amazons3_region'];
  }
  elseif (isset($form_state['values']['field']['settings']['amazons3_region']) && $form_state['values']['field']['settings']['amazons3_region']) {
    $config['region'] = $form_state['values']['field']['settings']['amazons3_region'];
  }
  if (!empty($hostname)) {
    $config['endpoint'] = $hostname;
  }
  try {
    $s3 = S3Client::factory($config, $bucket);
    S3Client::validateBucketExists($bucket, $s3);
  } catch (S3ConnectValidationException $e) {
    form_error($element, t('The S3 access credentials are invalid or the bucket does not exist.'));
    watchdog_exception('amazons3', $e);
  } catch (Exception $e) {
    form_error($element, t('There was a problem connecting to S3. The following exception was thrown: @exception', array(
      '@exception' => $e
        ->getMessage(),
    )));
    watchdog_exception('amazons3', $e);
  }
}

/**
 * Implements hook_field_default_field_bases_alter().
 *
 * Allows a variable to override all exported field bases to use 'Amazon S3' as
 * the Upload destination. For example this can be added to environment-specific
 * Drupal settings files, to allow certain environments to upload to S3 while
 * other environments upload to the exported (public or private) URI scheme:
 * @code
 * $conf['amazons3_file_uri_scheme_override'] = 's3';
 * @endcode
 */
function amazons3_field_default_field_bases_alter(&$fields) {
  if ($uri_scheme = variable_get('amazons3_file_uri_scheme_override', FALSE)) {
    foreach ($fields as $key => $item) {
      if (isset($item['settings']['uri_scheme'])) {
        $fields[$key]['settings']['uri_scheme'] = $uri_scheme;
      }
    }
  }
}

Functions

Namesort descending Description
amazons3_field_default_field_bases_alter Implements hook_field_default_field_bases_alter().
amazons3_field_info_alter Implements hook_field_info_alter().
amazons3_field_widget_form_alter Implements hook_field_widget_form_alter().
amazons3_field_widget_uri Return the destination URI for a file field.
amazons3_file_create_object Create a file object.
amazons3_file_entity_upload_destination_uri_alter Implements hook_file_entity_upload_destination_uri_alter().
amazons3_file_like_field Return an array of field types that are like a file field.
amazons3_file_stream_wrapper_uri_normalize_alter Implements hook_file_stream_wrapper_uri_normalize_alter().
amazons3_flush_caches Implements hook_flush_caches().
amazons3_form_bucket_validate Element validate callback to validate a bucket name.
amazons3_form_field_ui_field_edit_form_alter Implements hook_form_FORM_ID_alter().
amazons3_form_field_ui_field_settings_form_alter Implements hook_form_FORM_ID_alter().
amazons3_image_deliver Image delivery callback that uploads a derivative to S3.
amazons3_image_load Load an image, exiting if it could not be loaded.
amazons3_image_style_path_alter Implements hook_image_style_path_alter().
amazons3_menu Implements hook_menu().
amazons3_permission Implements hook_permission().
amazons3_stream_wrappers Implements hook_stream_wrappers().
amazons3_upload_location Return a URI for use in an #upload_location or similar form element.
amazons3_uri_add_bucket Add the default bucket and return a string URL.
_amazons3_field_configuration Add S3 configuration to file field settings forms.
_amazons3_generate_image_style Generate an image style derivative and upload it to S3.
_amazons3_image_wait_transfer Wait for an image to appear in a directory, and transfer it when it appears.