You are here

S3fsStreamWrapper.inc in S3 File System 7.3

Same filename and directory in other branches
  1. 7 S3fsStreamWrapper.inc
  2. 7.2 S3fsStreamWrapper.inc

Drupal stream wrapper implementation for S3 File System.

Implements DrupalStreamWrapperInterface to provide an Amazon S3 wrapper using the "s3://" scheme. It can optionally take over for the "public://" stream wrapper, too.

File

S3fsStreamWrapper.inc
View source
<?php

/**
 * @file
 * Drupal stream wrapper implementation for S3 File System.
 *
 * Implements DrupalStreamWrapperInterface to provide an Amazon S3 wrapper
 * using the "s3://" scheme. It can optionally take over for the "public://"
 * stream wrapper, too.
 */

// If using Libraries instead of Composer Manager, load the AWS SDK.
if (!module_exists('composer_manager')) {
  _s3fs_load_awssdk_library();
}
use Aws\CacheInterface;
use Aws\S3\Exception\S3Exception;
use Aws\S3\StreamWrapper;
use Aws\S3\S3ClientInterface;

/**
 * The stream wrapper class.
 *
 * In the docs for this class, anywhere you see "<scheme>", it can mean either
 * "s3" or "public", depending on which stream is currently being serviced.
 */
class S3fsStreamWrapper extends StreamWrapper implements DrupalStreamWrapperInterface {

  /**
   * Stream context (this is set by PHP when a context is used).
   *
   * @var resource
   */
  public $context;

  /**
   * Hash of opened stream parameters.
   *
   * @var array
   */
  private $params = array();

  /**
   * Module configuration for stream.
   *
   * @var array
   */
  private $config = array();

  /**
   * Mode in which the stream was opened.
   *
   * @var string
   */
  private $mode;

  /**
   * Instance URI referenced as "<scheme>://key".
   *
   * @var string
   */
  protected $uri = NULL;

  /**
   * The AWS SDK for PHP S3Client object.
   *
   * @var Aws\S3\S3Client
   */
  protected $s3 = NULL;

  /**
   * The opened protocol (e.g., "s3").
   *
   * @var string
   */
  private $protocol = 's3';

  /**
   * Domain we use to access files over http.
   *
   * @var string
   */
  protected $domain = NULL;

  /**
   * Directory listing used by the dir_* methods.
   *
   * @var array
   */
  protected $dir = NULL;

  /**
   * Map for files that should be delivered with a torrent URL.
   *
   * @var array
   */
  protected $torrents = array();

  /**
   * Files that the user has said must be downloaded, rather than viewed.
   *
   * @var array
   */
  protected $saveas = array();

  /**
   * Files which should be created with URLs that eventually time out.
   *
   * @var array
   */
  protected $presignedURLs = array();

  /**
   * Default map for determining file mime types.
   *
   * @var array
   */
  protected static $mimeTypeMapping = NULL;

  /**
   * Indicates the current error state in the wrapper.
   *
   * @var bool
   */
  protected $_error_state = FALSE;

  /**
   * Stream wrapper constructor.
   *
   * Creates the Aws\S3\S3Client client object and activates the options
   * specified on the S3 File System Settings page.
   */
  public function __construct() {

    // Since S3fsStreamWrapper is always constructed with the same inputs (the
    // file uri is not part of construction), we store the constructed settings
    // statically. This is important for performance because the way Drupal's
    // APIs are used causes stream wrappers to be frequently re-constructed.
    // Get the S3 client object and register the stream wrapper again so it is
    // configured as needed.
    $settings =& drupal_static('S3fsStreamWrapper_constructed_settings');
    if ($settings !== NULL) {
      $this->config = $settings['config'];
      $this->domain = $settings['domain'];
      $this->torrents = $settings['torrents'];
      $this->presignedURLs = $settings['presignedURLs'];
      $this->saveas = $settings['saveas'];
      $this->s3 = _s3fs_get_amazons3_client($this->config);
      $this
        ->register($this->s3);
      return;
    }

    // Begin construction if not cached.
    $this->config = _s3fs_get_config();
    $this->s3 = _s3fs_get_amazons3_client($this->config);
    $this
      ->register($this->s3);
    $this->context = stream_context_get_default();
    stream_context_set_option($this->context, 's3', 'seekable', TRUE);

    // Check if bucket is configured.
    if (empty($this->config['bucket'])) {
      $msg = t('Your AmazonS3 bucket name is not configured. Please visit the !settings_page.', array(
        '!settings_page' => l(t('configuration page'), '/admin/config/media/s3fs/settings'),
      ));
      watchdog('S3 File System', $msg, array(), WATCHDOG_ERROR);
      throw new Exception($msg);
    }

    // Always use HTTPS when the page is being served via HTTPS, to avoid
    // complaints from the browser about insecure content.
    global $is_https;
    if ($is_https) {

      // We change the config itself, rather than simply using $is_https in
      // the following if condition, because $this->config['use_https'] gets
      // used again later.
      $this->config['use_https'] = TRUE;
    }
    $scheme = !empty($this->config['use_https']) ? 'https' : 'http';

    // CNAME support for customizing S3 URLs.
    // If use_cname is not enabled, file URLs do not use $this->domain.
    if (!empty($this->config['use_cname']) && !empty($this->config['domain'])) {
      $domain = check_url($this->config['domain']);
      if ($domain) {

        // If domain is set to a root-relative path, add the hostname back in.
        if (strpos($domain, '/') === 0) {
          $domain = $_SERVER['HTTP_HOST'] . $domain;
        }
        $this->domain = "{$scheme}://{$domain}";
      }
      else {

        // Due to the config form's validation, this shouldn't ever happen.
        throw new Exception(t('The "Use a CNAME" option is enabled, but no CDN Domain Name has been set.'));
      }
    }

    // Convert the torrents string to an array.
    if (!empty($this->config['torrents'])) {
      foreach (explode("\n", $this->config['torrents']) as $line) {
        $blob = trim($line);
        if ($blob) {
          $this->torrents[] = $blob;
        }
      }
    }

    // Convert the presigned URLs string to an associative array like
    // array(blob => timeout).
    if (!empty($this->config['presigned_urls'])) {
      foreach (explode("\n", $this->config['presigned_urls']) as $line) {
        $blob = trim($line);
        if ($blob) {
          $matches = array();
          if (preg_match('/(.*)\\|(.*)/', $blob, $matches)) {
            $blob = $matches[2];
            $timeout = $matches[1];
            $this->presignedURLs[$blob] = $timeout;
          }
          else {
            $this->presignedURLs[$blob] = 60;
          }
        }
      }
    }

    // Convert the forced save-as string to an array.
    if (!empty($this->config['saveas'])) {
      foreach (explode("\n", $this->config['saveas']) as $line) {
        $blob = trim($line);
        if ($blob) {
          $this->saveas[] = $blob;
        }
      }
    }

    // Save all the work we just did, so that subsequent S3fsStreamWrapper
    // constructions don't have to repeat it.
    $settings['config'] = $this->config;
    $settings['domain'] = $this->domain;
    $settings['torrents'] = $this->torrents;
    $settings['presignedURLs'] = $this->presignedURLs;
    $settings['saveas'] = $this->saveas;
  }

  /**
   * Ensure the S3 protocol is registered to this class and not parents.
   *
   * @param \Aws\S3\S3ClientInterface $client
   * @param string $protocol
   * @param \Aws\CacheInterface|null $cache
   */
  public static function register(S3ClientInterface $client, $protocol = 's3', CacheInterface $cache = null) {
    parent::register($client, $protocol, $cache);
  }

  /***************************************************************************
                   DrupalStreamWrapperInterface Implementations
    ***************************************************************************/
  public static function getMimeType($uri, $mapping = NULL) {

    // Load the default mime type map.
    if (!isset(self::$mimeTypeMapping)) {
      include_once DRUPAL_ROOT . '/includes/file.mimetypes.inc';
      self::$mimeTypeMapping = file_mimetype_mapping();
    }

    // If a mapping wasn't specified, use the default map.
    if ($mapping == NULL) {
      $mapping = self::$mimeTypeMapping;
    }
    $extension = '';
    $file_parts = explode('.', drupal_basename($uri));

    // Remove the first part: a full filename should not match an extension.
    array_shift($file_parts);

    // Iterate over the file parts, trying to find a match.
    // For my.awesome.image.jpeg, we try:
    // - jpeg
    // - image.jpeg
    // - awesome.image.jpeg
    while ($additional_part = array_pop($file_parts)) {
      $extension = strtolower($additional_part . ($extension ? '.' . $extension : ''));
      if (isset($mapping['extensions'][$extension])) {
        return $mapping['mimetypes'][$mapping['extensions'][$extension]];
      }
    }

    // No mime type matches, so return the default.
    return 'application/octet-stream';
  }

  /**
   * Sets the stream resource URI. URIs are formatted as "<scheme>://filepath".
   *
   * @param string $uri
   *   The URI that should be used for this instance.
   */
  public function setUri($uri) {
    $this->uri = $uri;
  }

  /**
   * Returns the stream resource URI, which looks like "<scheme>://filepath".
   *
   * @return string
   *   The current URI of the instance.
   */
  public function getUri() {
    return $this->uri;
  }

  /**
   * Returns a web accessible URL for the resource.
   *
   * The format of the returned URL will be different depending on how the S3
   * integration has been configured on the S3 File System admin page.
   *
   * @param bool $no_redirect
   *  A custom parameter for internal use by s3fs.
   *
   * @return string
   *   A web accessible URL for the resource.
   */
  public function getExternalUrl() {

    // In case we're on Windows, replace backslashes with forward-slashes.
    // Note that $uri is the unaltered value of the File's URI, while
    // $s3_key may be changed at various points to account for implementation
    // details on the S3 side (e.g. root_folder, s3fs_public_folder).
    $s3_key = str_replace('\\', '/', file_uri_target($this->uri));

    // If this is a private:// file, it must be served through the
    // system/files/$uri URL, which allows Drupal to restrict access
    // based on who's logged in.
    if (file_uri_scheme($this->uri) == 'private') {
      return url("system/files/{$s3_key}", array(
        'absolute' => TRUE,
      ));
    }

    // When generating an image derivative URL, e.g. styles/thumbnail/blah.jpg,
    // if the file doesn't exist, provide a URL to s3fs's special version of
    // image_style_deliver(), which will create the derivative when that URL
    // gets requested.
    $uri_parts = explode('/', $s3_key);
    if ($uri_parts[0] == 'styles' && substr($s3_key, -4) != '.css') {
      $metadata = $this
        ->_s3fs_get_object($this->uri);
      $is_zero_byte = !isset($metadata['filesize']) || $metadata['filesize'] == 0;
      if (!$metadata || $is_zero_byte) {
        if ($is_zero_byte) {
          $this
            ->unlink($this->uri);
        }

        // The style delivery path looks like: s3/files/styles/thumbnail/...
        // And $uri_parts looks like array('styles', 'thumbnail', ...),
        // so just prepend s3/files/.
        array_unshift($uri_parts, 's3', 'files');
        return url(implode('/', $uri_parts), array(
          'absolute' => TRUE,
        ));
      }
      unset($metadata);
    }

    // Deal with public:// files.
    if (file_uri_scheme($this->uri) == 'public') {

      // Rewrite all css/js file paths unless the user has told us not to.
      if (empty($this->config['no_rewrite_cssjs'])) {
        if (substr($s3_key, -4) == '.css') {

          // Send requests for public CSS files to /s3fs-css/path/to/file.css.
          // Users must set that path up in the webserver config as a proxy into
          // their S3 bucket's s3fs_public_folder.
          return "{$GLOBALS['base_url']}/s3fs-css/" . drupal_encode_path($s3_key);
        }
        elseif (substr($s3_key, -3) == '.js') {

          // Send requests for public JS files to /s3fs-js/path/to/file.js.
          // Like with CSS, the user must set up that path as a proxy.
          return "{$GLOBALS['base_url']}/s3fs-js/" . drupal_encode_path($s3_key);
        }
      }

      // public:// files are stored in S3 inside the s3fs_public_folder.
      $public_folder = !empty($this->config['public_folder']) ? $this->config['public_folder'] : 's3fs-public';

      // If domain root is not set, or the value is set to map to the root
      // folder, include the public folder.
      if ($this->config['domain_root'] == 'none' || $this->config['domain_root'] == 'root') {
        $s3_key = "{$public_folder}/{$s3_key}";
      }
    }

    // Set up the URL settings as specified in our settings page.
    $url_settings = array(
      'torrent' => FALSE,
      'presigned_url' => FALSE,
      'timeout' => 60,
      'forced_saveas' => FALSE,
      'api_args' => array(
        'Scheme' => !empty($this->config['use_https']) ? 'https' : 'http',
      ),
      'custom_GET_args' => array(),
    );

    // Presigned URLs.
    foreach ($this->presignedURLs as $blob => $timeout) {

      // ^ is used as the delimeter because it's an illegal character in URLs.
      if (preg_match("^{$blob}^", $s3_key)) {
        $url_settings['presigned_url'] = TRUE;
        $url_settings['timeout'] = $timeout;
        break;
      }
    }

    // Forced Save As.
    foreach ($this->saveas as $blob) {
      if (preg_match("^{$blob}^", $s3_key)) {
        $filename = drupal_basename($s3_key);
        $url_settings['api_args']['ResponseContentDisposition'] = "attachment; filename=\"{$filename}\"";
        $url_settings['forced_saveas'] = TRUE;
        break;
      }
    }

    // Allow other modules to change the URL settings.
    drupal_alter('s3fs_url_settings', $url_settings, $s3_key);

    // If a root folder has been set, and domain root is not set, prepend
    // the root folder to the $s3_key at this time.
    if (!empty($this->config['root_folder']) && $this->config['domain_root'] == 'none') {
      $s3_key = "{$this->config['root_folder']}/{$s3_key}";
    }
    if (empty($this->config['use_cname'])) {

      // We're not using a CNAME, so we ask S3 for the URL.
      $expires = NULL;
      if ($url_settings['presigned_url']) {
        $expires = "+{$url_settings['timeout']} seconds";
      }
      else {

        // Due to Amazon's security policies (see Request Parameters section @
        // http://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectGET.html),
        // only signed requests can use request parameters.
        // Thus, we must provide an expiry time for any URLs which specify
        // Response* API args. Currently, this only includes "Forced Save As".
        foreach ($url_settings['api_args'] as $key => $arg) {
          if (strpos($key, 'Response') === 0) {
            $expires = "+10 years";
            break;
          }
        }
      }
      if ($url_settings['presigned_url']) {
        $cmd = $this->s3
          ->getCommand('GetObject', array(
          'Bucket' => $this->config['bucket'],
          'Key' => $s3_key,
        ));
        $external_url = (string) $this->s3
          ->createPresignedRequest($cmd, $expires)
          ->getUri();
      }
      else {
        $external_url = $this->s3
          ->getObjectUrl($this->config['bucket'], $s3_key);
      }
    }
    else {

      // We are using a CNAME, so we need to manually construct the URL.
      $external_url = "{$this->domain}/{$s3_key}";
    }

    // If this file is versioned, append the version number as a GET arg to
    // ensure that browser caches will be bypassed upon version changes.
    $meta = $this
      ->_read_cache($this->uri);
    if (!empty($meta['version']) && !empty($this->config['use_versioning'])) {
      $external_url = $this
        ->_append_get_arg($external_url, $meta['version']);
    }

    // Torrents can only be created for publicly-accessible files:
    // https://forums.aws.amazon.com/thread.jspa?threadID=140949
    // So Forced SaveAs and Presigned URLs cannot be served as torrents.
    if (!$url_settings['forced_saveas'] && !$url_settings['presigned_url']) {
      foreach ($this->torrents as $blob) {
        if (preg_match("^{$blob}^", $s3_key)) {

          // You get a torrent URL by adding a "torrent" GET arg.
          $external_url = $this
            ->_append_get_arg($external_url, 'torrent');
          break;
        }
      }
    }

    // If another module added a 'custom_GET_args' array to the url settings, process it here.
    if (!empty($url_settings['custom_GET_args'])) {
      foreach ($url_settings['custom_GET_args'] as $name => $value) {
        $external_url = $this
          ->_append_get_arg($external_url, $name, $value);
      }
    }
    return $external_url;
  }

  /**
   * Gets the path that the wrapper is responsible for.
   *
   * This function isn't part of DrupalStreamWrapperInterface, but the rest
   * of Drupal calls it as if it were, so we need to define it.
   *
   * @return string
   *   The empty string. Since this is a remote stream wrapper,
   *   it has no directory path.
   */
  public function getDirectoryPath() {
    return '';
  }

  /***************************************************************************
                Public Functions for External Use of the Wrapper
    ***************************************************************************/

  /**
   * Wait for the specified file to exist in the bucket.
   *
   * @param string $uri
   *   The uri of the file.
   *
   * @return bool
   *   Returns TRUE once the waiting finishes, or FALSE if the file does not
   *   begin to exist within 10 seconds.
   */
  public function waitUntilFileExists($uri) {

    // Retry ten times, once every second.
    $params = $this
      ->getCommandParams($uri);
    $params['@waiter'] = array(
      'delay' => 1,
      'maxAttempts' => 10,
    );
    try {
      $this->s3
        ->waitUntil('ObjectExists', $params);
      return TRUE;
    } catch (S3Exception $e) {
      watchdog_exception('S3FS', $e);
      return FALSE;
    }
  }

  /**
   * Write the file at the given uri into the metadata cache.
   *
   * This function is public so that other code can upload files to S3 and
   * then have us write the correct metadata into our cache.
   */
  public function writeUriToCache($uri) {
    if ($this
      ->waitUntilFileExists($uri)) {
      $metadata = $this
        ->_get_metadata_from_s3($uri);
      $this
        ->_write_cache($metadata);
      clearstatcache(TRUE, $uri);
    }
  }

  /***************************************************************************
                        PHP Stream Wrapper Implementations
    ***************************************************************************/

  /**
   * This wrapper doesn't support file permissions.
   *
   * @param int $mode
   *   The file's desired permissions in octal. Consult PHP chmod() documentation
   *   for more information.
   *
   * @return bool
   *   Always returns TRUE.
   */
  public function chmod($mode) {
    $octal_mode = decoct($mode);
    return TRUE;
  }

  /**
   * This wrapper does not support realpath().
   *
   * @return bool
   *   Always returns FALSE.
   */
  public function realpath() {
    return FALSE;
  }

  /**
   * Gets the name of the parent directory of a given path.
   *
   * This method is usually accessed through drupal_dirname(), which wraps
   * around the normal PHP dirname() function, since it doesn't support stream
   * wrappers.
   *
   * @param string $uri
   *   An optional URI.
   *
   * @return string
   *   The directory name, or FALSE if not applicable.
   *
   * @see drupal_dirname()
   */
  public function dirname($uri = NULL) {
    if (!isset($uri)) {
      $uri = $this->uri;
    }
    $scheme = file_uri_scheme($uri);
    $dirname = dirname(file_uri_target($uri));

    // When the dirname() call above is given '$scheme://', it returns '.'.
    // But '$scheme://.' is an invalid uri, so we return "$scheme://" instead.
    if ($dirname == '.') {
      $dirname = '';
    }
    return "{$scheme}://{$dirname}";
  }

  /**
   * Support for fopen(), file_get_contents(), file_put_contents() etc.
   *
   * @param string $uri
   *   The URI of the file to open.
   * @param string $mode
   *   The file mode. Only 'r', 'w', 'a', and 'x' are supported.
   * @param int $options
   *   A bit mask of STREAM_USE_PATH and STREAM_REPORT_ERRORS.
   * @param string $opened_path
   *   An OUT parameter populated with the path which was opened.
   *   This wrapper does not support this parameter.
   *
   * @return bool
   *   TRUE if file was opened successfully. Otherwise, FALSE.
   *
   * @see http://php.net/manual/en/streamwrapper.stream-open.php
   */
  public function stream_open($uri, $mode, $options, &$opened_path) {
    $this
      ->setUri($uri);
    $converted = $this
      ->convertUriToKeyedPath($uri);
    return parent::stream_open($converted, $mode, $options, $opened_path);
  }

  /**
   * This wrapper does not support flock().
   *
   * @return bool
   *   Always Returns FALSE.
   *
   * @see http://php.net/manual/en/streamwrapper.stream-lock.php
   */
  public function stream_lock($operation) {
    return FALSE;
  }
  public function stream_metadata($path, $option, $value) {
    return FALSE;
  }

  /**
   * Support for fflush(). Flush current cached stream data to a file in S3.
   *
   * @return bool
   *   TRUE if data was successfully stored in S3.
   *
   * @see http://php.net/manual/en/streamwrapper.stream-flush.php
   */
  public function stream_flush() {

    // Prepare upload parameters.
    $options = $this
      ->getOptions();
    $options[$this->protocol]['ContentType'] = $this
      ->getMimeType($this->uri);

    // All non-private files uploaded to S3 must be set to public-read, or users' browsers
    // will get PermissionDenied errors, and torrent URLs won't work. The one exception to
    // this is when all content is being routed through an edge service and access via S3
    // should be blocked.
    if (!empty($this->config['use_cname']) && !empty($this->config['domain']) && !empty($this->config['domain_s3_private'])) {
      $options[$this->protocol]['ACL'] = 'private';
    }
    elseif (file_uri_scheme($this->uri) != 'private') {

      // All non-private files uploaded to S3 must be set to public-read, or users' browsers
      // will get PermissionDenied errors, and torrent URLs won't work.
      $options[$this->protocol]['ACL'] = 'public-read';
    }

    // Set the Cache-Control header, if the user specified one.
    if (!empty($this->config['cache_control_header'])) {
      $options[$this->protocol]['CacheControl'] = $this->config['cache_control_header'];
    }
    if (!empty($this->config['encryption'])) {
      $options[$this->protocol]['ServerSideEncryption'] = $this->config['encryption'];
    }

    // Legacy support: set bucket and key values that were present with
    // AWS SDK v2. Used by AdvAgg module with hook_s3fs_upload_params_alter().
    $options[$this->protocol]['Bucket'] = $this->config['bucket'];
    $convertedPath = $this
      ->convertUriToKeyedPath($this->uri, FALSE);
    $options[$this->protocol]['Key'] = file_uri_target($convertedPath);

    // Allow other modules to alter the upload params.
    drupal_alter('s3fs_upload_params', $options[$this->protocol]);

    // Legacy support: unset bucket and key values. Retaining them will
    // cause conflicts when saving AdvAgg's aggregated css/js files.
    unset($options[$this->protocol]['Bucket']);
    unset($options[$this->protocol]['Key']);
    stream_context_set_option($this->context, $options);
    if (parent::stream_flush()) {
      $this
        ->writeUriToCache($this->uri);
      return TRUE;
    }
    else {
      return FALSE;
    }
  }

  /**
   * Support for unlink().
   *
   * @param string $uri
   *   The uri of the resource to delete.
   *
   * @return bool
   *   TRUE if resource was successfully deleted, regardless of whether or not
   *   the file actually existed.
   *   FALSE if the call to S3 failed, in which case the file will not be
   *   removed from the cache.
   *
   * @see http://php.net/manual/en/streamwrapper.unlink.php
   */
  public function unlink($uri) {
    $this
      ->setUri($uri);
    $converted = $this
      ->convertUriToKeyedPath($uri);
    if (parent::unlink($converted)) {
      $this
        ->_delete_cache($uri);
      clearstatcache(TRUE, $uri);
      return TRUE;
    }
    else {
      return FALSE;
    }
  }
  public function url_stat($uri, $flags) {
    $this
      ->setUri($uri);
    return $this
      ->_stat($uri);
  }

  /**
   * Support for mkdir().
   *
   * @param string $uri
   *   The URI to the directory to create.
   * @param int $mode
   *   Permission flags - see mkdir().
   * @param int $options
   *   A bit mask of STREAM_REPORT_ERRORS and STREAM_MKDIR_RECURSIVE.
   *
   * @return bool
   *   TRUE if the directory was successfully created. Otherwise, FALSE.
   *
   * @see http://php.net/manual/en/streamwrapper.mkdir.php
   */
  public function mkdir($uri, $mode, $options) {

    // Some Drupal plugins call mkdir with a trailing slash. We mustn't store
    // that slash in the cache.
    $uri = rtrim($uri, '/');
    clearstatcache(TRUE, $uri);

    // If this uri already exists in the cache, return TRUE if it's a folder
    // (so that recursive calls won't improperly report failure when they
    // reach an existing ancestor), or FALSE if it's a file (failure).
    $test_metadata = $this
      ->_read_cache($uri);
    if ($test_metadata) {
      return (bool) $test_metadata['dir'];
    }

    // S3 is a flat file system, with no concept of directories (just files
    // with slashes in their names). We store folders in the metadata cache,
    // but don't create an object for them in S3.
    $metadata = _s3fs_convert_metadata($uri, array());
    $this
      ->_write_cache($metadata);

    // If the STREAM_MKDIR_RECURSIVE option was specified, also create all the
    // ancestor folders of this uri, except for the root directory.
    $parent_dir = drupal_dirname($uri);
    if ($options & STREAM_MKDIR_RECURSIVE && file_uri_target($parent_dir) != '') {
      return $this
        ->mkdir($parent_dir, $mode, $options);
    }
    return TRUE;
  }

  /**
   * Support for rmdir().
   *
   * @param string $uri
   *   The URI to the folder to delete.
   * @param int $options
   *   A bit mask of STREAM_REPORT_ERRORS.
   *
   * @return bool
   *   TRUE if folder is successfully removed.
   *   FALSE if $uri isn't a folder, or the folder is not empty.
   *
   * @see http://php.net/manual/en/streamwrapper.rmdir.php
   */
  public function rmdir($uri, $options) {
    if (!$this
      ->_path_is_dir($uri)) {
      return FALSE;
    }

    // We need a version of $uri with no / because folders are cached with no /.
    // We also need one with the /, because it might be a file in S3 that
    // ends with /. In addition, we must differentiate against files with this
    // folder's name as a substring.
    // e.g. rmdir('s3://foo/bar') should ignore s3://foo/barbell.jpg.
    $bare_path = rtrim($uri, '/');
    $slash_path = $bare_path . '/';

    // If the folder is empty, it's eligible for deletion.
    $file_count = db_select('s3fs_file', 's')
      ->fields('s')
      ->condition('uri', db_like($slash_path) . '%', 'LIKE')
      ->execute()
      ->rowCount();
    if ($file_count === 0) {
      if (parent::rmdir($this
        ->convertUriToKeyedPath($uri), $options)) {
        $this
          ->_delete_cache($uri);
        clearstatcache(TRUE, $uri);
        return TRUE;
      }
    }

    // The folder is non-empty.
    return FALSE;
  }

  /**
   * Support for opendir().
   *
   * @param string $uri
   *   The URI to the directory to open.
   * @param int $options
   *   A flag used to enable safe_mode.
   *   This wrapper doesn't support safe_mode, so this parameter is ignored.
   *
   * @return bool
   *   TRUE on success. Otherwise, FALSE.
   *
   * @see http://php.net/manual/en/streamwrapper.dir-opendir.php
   */
  public function dir_opendir($uri, $options = NULL) {
    if (!$this
      ->_path_is_dir($uri)) {
      return FALSE;
    }
    $scheme = file_uri_scheme($uri);
    $bare_path = rtrim($uri, '/');
    $slash_path = $bare_path . '/';

    // If this path was originally a root folder (e.g. s3://), the above code
    // removed *both* slashes but only added one back. So we need to add
    // back the second slash.
    if ($slash_path == "{$scheme}:/") {
      $slash_path = "{$scheme}://";
    }

    // Get the list of paths for files and folders which are children of the
    // specified folder, but not grandchildren.
    $child_paths = db_select('s3fs_file', 's')
      ->fields('s', array(
      'uri',
    ))
      ->condition('uri', db_like($slash_path) . '%', 'LIKE')
      ->condition('uri', db_like($slash_path) . '%/%', 'NOT LIKE')
      ->execute()
      ->fetchCol(0);
    $this->dir = array();
    foreach ($child_paths as $child_path) {
      $this->dir[] = drupal_basename($child_path);
    }
    return TRUE;
  }

  /**
   * Support for readdir().
   *
   * @return string
   *   The next filename, or FALSE if there are no more files in the directory.
   *
   * @see http://php.net/manual/en/streamwrapper.dir-readdir.php
   */
  public function dir_readdir() {
    $current = current($this->dir);
    if ($current) {
      next($this->dir);
    }
    return $current;
  }
  public function rename($from_path, $to_path) {

    // Set access for new item in stream context.
    if (file_uri_scheme($from_path) != 'private') {
      stream_context_set_option($this->context, 's3', 'ACL', 'public-read');
    }

    // If parent succeeds in renaming, updated local metadata and cache.
    if (parent::rename($this
      ->convertUriToKeyedPath($from_path), $this
      ->convertUriToKeyedPath($to_path))) {
      $metadata = $this
        ->_read_cache($from_path);
      $metadata['uri'] = $to_path;
      $this
        ->_write_cache($metadata);
      $this
        ->_delete_cache($from_path);
      clearstatcache(TRUE, $from_path);
      clearstatcache(TRUE, $to_path);
      return TRUE;
    }
    else {
      return FALSE;
    }
  }

  /***************************************************************************
                                Internal Functions
    ***************************************************************************/

  /**
   * Determine whether the $uri is a directory.
   *
   * @param string $uri
   *   The path of the resource to check.
   *
   * @return bool
   *   TRUE if the resource is a directory.
   */
  protected function _path_is_dir($uri) {
    $metadata = $this
      ->_s3fs_get_object($uri);
    return $metadata ? $metadata['dir'] : FALSE;
  }

  /**
   * Implementation of a stat method to ensure that remote files don't fail
   * checks when they should pass.
   *
   * @param $uri
   * @return array|bool
   */
  protected function _stat($uri) {
    $metadata = $this
      ->_s3fs_get_object($uri);
    if ($metadata) {
      $stat = array();
      $stat[0] = $stat['dev'] = 0;
      $stat[1] = $stat['ino'] = 0;

      // Use the S_IFDIR posix flag for directories, S_IFREG for files.
      // All files are considered writable, so OR in 0777.
      $stat[2] = $stat['mode'] = ($metadata['dir'] ? 040000 : 0100000) | 0777;
      $stat[3] = $stat['nlink'] = 0;
      $stat[4] = $stat['uid'] = 0;
      $stat[5] = $stat['gid'] = 0;
      $stat[6] = $stat['rdev'] = 0;
      $stat[7] = $stat['size'] = 0;
      $stat[8] = $stat['atime'] = 0;
      $stat[9] = $stat['mtime'] = 0;
      $stat[10] = $stat['ctime'] = 0;
      $stat[11] = $stat['blksize'] = 0;
      $stat[12] = $stat['blocks'] = 0;
      if (!$metadata['dir']) {
        $stat[4] = $stat['uid'] = 's3fs';
        $stat[7] = $stat['size'] = $metadata['filesize'];
        $stat[8] = $stat['atime'] = $metadata['timestamp'];
        $stat[9] = $stat['mtime'] = $metadata['timestamp'];
        $stat[10] = $stat['ctime'] = $metadata['timestamp'];
      }
      return $stat;
    }
    return FALSE;
  }

  /**
   * Try to fetch an object from the metadata cache.
   *
   * If that file isn't in the cache, we assume it does not exist.
   *
   * @param string $uri
   *   The uri of the resource to check.
   *
   * @return array|bool
   *   An array if the $uri exists, otherwise FALSE.
   */
  protected function _s3fs_get_object($uri) {

    // For the root directory, return metadata for a generic folder.
    if (file_uri_target($uri) == '') {
      return _s3fs_convert_metadata('/', array());
    }

    // Trim any trailing '/', in case this is a folder request.
    $uri = rtrim($uri, '/');

    // Check if this uri is in the cache.
    $metadata = $this
      ->_read_cache($uri);

    // If cache ignore is enabled, query S3 for all uris which aren't in the
    // cache, and non-folder uris which are.
    if (!empty($this->config['ignore_cache']) && !$metadata['dir']) {
      try {

        // If _get_metadata_from_s3() returns FALSE, the file doesn't exist.
        $metadata = $this
          ->_get_metadata_from_s3($uri);
      } catch (\Exception $e) {
        return $this
          ->_trigger_error($e
          ->getMessage());
      }
    }
    return $metadata;
  }

  /**
   * Fetch an object from the file metadata cache table.
   *
   * @param string $uri
   *   The uri of the resource to check.
   *
   * @return array|bool
   *   An array of metadata if the $uri is in the cache. Otherwise, FALSE.
   */
  protected function _read_cache($uri) {

    // Since public:///blah.jpg and public://blah.jpg refer to the same file
    // (a file named blah.jpg at the root of the file system), we'll sometimes
    // receive files with a /// in their uri. This messes with our caching
    // scheme, though, so we need to remove the extra /.
    if (strpos($uri, 'public:///') === 0) {
      $uri = preg_replace('^public://[/]+^', 'public://', $uri);
    }
    elseif (strpos($uri, 'private:///') === 0) {
      $uri = preg_replace('^private://[/]+^', 'private://', $uri);
    }
    $record = db_select('s3fs_file', 's')
      ->fields('s')
      ->condition('uri', $uri, '=')
      ->execute()
      ->fetchAssoc();
    return $record ? $record : FALSE;
  }

  /**
   * Write an object's (and its ancestor folders') metadata to the cache.
   *
   * @param array $metadata
   *   An associative array of file metadata in this format:
   *     'uri' => The full URI of the file, including the scheme.
   *     'filesize' => The size of the file, in bytes.
   *     'timestamp' => The file's create/update timestamp.
   *     'dir' => A boolean indicating whether the object is a directory.
   *
   * @throws
   *   Exceptions which occur in the database call will percolate.
   */
  protected function _write_cache($metadata) {

    // Since public:///blah.jpg and public://blah.jpg refer to the same file
    // (a file named blah.jpg at the root of the file system), we'll sometimes
    // receive files with a /// in their URI. This messes with our caching
    // scheme, though, so we need to remove the extra /.
    if (strpos($metadata['uri'], 'public:///') === 0) {
      $metadata['uri'] = preg_replace('^public://[/]+^', 'public://', $metadata['uri']);
    }
    elseif (strpos($metadata['uri'], 'private:///') === 0) {
      $metadata['uri'] = preg_replace('^private://[/]+^', 'private://', $metadata['uri']);
    }
    db_merge('s3fs_file')
      ->key(array(
      'uri' => $metadata['uri'],
    ))
      ->fields($metadata)
      ->execute();
    $dirname = drupal_dirname($metadata['uri']);

    // If this file isn't in the root directory, also write this file's
    // ancestor folders to the cache.
    if (file_uri_target($dirname) != '') {
      $this
        ->mkdir($dirname, NULL, STREAM_MKDIR_RECURSIVE);
    }
  }

  /**
   * Delete an object's metadata from the cache.
   *
   * @param mixed $uri
   *   A string (or array of strings) containing the uri(s) of the object(s)
   *   to be deleted.
   *
   * @return object|bool
   *   Returns a query object or FALSE.
   *
   * @throws
   *   Exceptions which occur in the database call will percolate.
   */
  protected function _delete_cache($uri) {
    if (!is_array($uri)) {
      $uri = array(
        $uri,
      );
    }

    // Build an OR query to delete all the URIs at once.
    $delete_query = db_delete('s3fs_file');
    $or = db_or();
    foreach ($uri as $u) {
      $or
        ->condition('uri', $u, '=');
    }
    $delete_query
      ->condition($or);
    return $delete_query
      ->execute();
  }

  /**
   * Get the stream's context options or remove them if wanting default.
   *
   * @param bool $removeContextData
   *   Whether to remove the stream's context information.
   *
   * @return array
   *   An array of options.
   */
  public function getOptions($removeContextData = false) {

    // Context is not set when doing things like stat
    if (is_null($this->context)) {
      $this->context = stream_context_get_default();
    }
    $options = stream_context_get_options($this->context);
    if ($removeContextData) {
      unset($options['client'], $options['seekable'], $options['cache']);
    }
    return $options;
  }

  /**
   * Converts a Drupal URI path into what is expected to be stored in S3.
   *
   * @param $uri
   *   An appropriate URI formatted like 'protocol://path'.
   * @param bool $prepend_bucket
   *   Whether to prepend the bucket name. S3's stream wrapper requires this for
   *   some functions.
   *
   * @return string
   *   A converted string ready for S3 to process it.
   */
  protected function convertUriToKeyedPath($uri, $prepend_bucket = TRUE) {

    // Remove the protocol
    $parts = explode('://', $uri);
    if (!empty($parts[1])) {

      // public:// file are all placed in the s3fs_public_folder.
      $public_folder = !empty($this->config['public_folder']) ? $this->config['public_folder'] : 's3fs-public';
      $private_folder = !empty($this->config['private_folder']) ? $this->config['private_folder'] : 's3fs-private';
      if (file_uri_scheme($uri) == 'public') {
        $parts[1] = "{$public_folder}/{$parts[1]}";
      }
      elseif (file_uri_scheme($uri) == 'private') {
        $parts[1] = "{$private_folder}/{$parts[1]}";
      }

      // If it's set, all files are placed in the root folder.
      if (!empty($this->config['root_folder'])) {
        $parts[1] = "{$this->config['root_folder']}/{$parts[1]}";
      }

      // Prepend the uri with a bucket since AWS SDK expects this.
      if ($prepend_bucket) {
        $parts[1] = $this->config['bucket'] . '/' . $parts[1];
      }
    }

    // Set protocol to S3 so AWS stream wrapper works correctly.
    $parts[0] = 's3';
    return implode('://', $parts);
  }

  /**
   * Return bucket and key for a command array.
   *
   * @param string $uri
   *   Uri to the required object.
   *
   * @return array
   *   A modified path to the key in S3.
   */
  protected function getCommandParams($uri) {
    $convertedPath = $this
      ->convertUriToKeyedPath($uri, FALSE);
    $params = $this
      ->getOptions(true);
    $params['Bucket'] = $this->config['bucket'];
    $params['Key'] = file_uri_target($convertedPath);
    return $params;
  }

  /**
   * Returns the converted metadata for an object in S3.
   *
   * @param string $uri
   *   The uri for the object in S3.
   *
   * @return array|bool
   *   An array of DB-compatible file metadata.
   *
   * @throws \Exception
   *   Any exception raised by the listObjects() S3 command will percolate
   *   out of this function.
   */
  protected function _get_metadata_from_s3($uri) {
    $params = $this
      ->getCommandParams($uri);
    try {
      $result = $this->s3
        ->headObject($params);
    } catch (S3Exception $e) {

      // headObject() throws this exception if the requested key doesn't exist
      // in the bucket.
      watchdog_exception('S3FS', $e);
      return FALSE;
    }
    return _s3fs_convert_metadata($uri, $result);
  }

  /**
   * Triggers one or more errors.
   *
   * @param string|array $errors
   *   Errors to trigger.
   * @param mixed $flags
   *   If set to STREAM_URL_STAT_QUIET, no error or exception is triggered.
   *
   * @return bool
   *   Always returns FALSE.
   *
   * @throws RuntimeException
   *   If the 'throw_exceptions' option is TRUE.
   */
  protected function _trigger_error($errors, $flags = NULL) {
    if ($flags != STREAM_URL_STAT_QUIET) {
      trigger_error(implode("\n", (array) $errors), E_USER_ERROR);
    }
    $this->_error_state = TRUE;
    return FALSE;
  }

  /**
   * Helper function to safely append a GET argument to a given base URL.
   *
   * @param string $base_url
   *   The URL onto which the GET arg will be appended.
   * @param string $name
   *   The name of the GET argument.
   * @param string $value
   *   The value of the GET argument. Optional.
   *
   * @return string
   *   The converted path GET argument.
   */
  protected static function _append_get_arg($base_url, $name, $value = NULL) {
    $separator = strpos($base_url, '?') === FALSE ? '?' : '&';
    $new_url = "{$base_url}{$separator}{$name}";
    if ($value !== NULL) {
      $new_url .= "={$value}";
    }
    return $new_url;
  }

}

Classes

Namesort descending Description
S3fsStreamWrapper The stream wrapper class.