You are here

S3fsStreamWrapper.inc in S3 File System 7

Same filename and directory in other branches
  1. 7.3 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.

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.
 */

/**
 * The stream wrapper class.
 */
class S3fsStreamWrapper implements DrupalStreamWrapperInterface {

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

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

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

  /**
   * 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();

  /**
   * The header that controls how browsers should cache a file.
   *
   * @var string
   */
  protected $cacheControlHeader = NULL;

  /**
   * The constructor sets this to TRUE once it's finished.
   *
   * See the comment on _assert_constructor_called() for why this exists.
   *
   * @var bool
   */
  protected $constructed = FALSE;

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

  /**
   * 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), store the constructed settings
    // statically. This is important for performance because Drupal
    // re-constructs stream wrappers very often.
    $settings =& drupal_static('S3fsStreamWrapper_constructed_settings');
    if ($settings !== NULL) {
      $this->config = $settings['config'];
      $this->s3 = _s3fs_get_amazons3_client($this->config);
      $this->domain = $settings['domain'];
      $this->torrents = $settings['torrents'];
      $this->presignedURLs = $settings['presignedURLs'];
      $this->saveas = $settings['saveas'];
      $this->cacheControlHeader = $settings['cache_control_header'];
      $this->constructed = TRUE;
      return;
    }
    $this->config = _s3fs_get_config();
    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);
    }

    // Get the S3 client object.
    $this->s3 = _s3fs_get_amazons3_client($this->config);

    // 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;
    }
    if (!empty($this->config['use_https'])) {
      $scheme = 'https';
      $this
        ->_debug('Using HTTPS.');
    }
    else {
      $scheme = 'http';
      $this
        ->_debug('Using 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 CNAME" option is enabled, but no Domain Name has been set. S3fsStreamWrapper construction cannot continue.'));
      }
    }

    // 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) {
          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;
        }
      }
    }
    $this->cacheControlHeader = !empty($this->config['cache_control_header']) ? $this->config['cache_control_header'] : NULL;

    // 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;
    $settings['cache_control_header'] = $this->cacheControlHeader;
    $this->constructed = TRUE;
    $this
      ->_debug('S3fsStreamWrapper constructed.');
  }

  /***************************************************************************
    DrupalStreamWrapperInterface Implementations
    ***************************************************************************/

  /**
   * Static function to determine a file's media type.
   *
   * Uses Drupal's mimetype mapping, unless a different mapping is specified.
   *
   * @return string
   *   Returns a string representing the file's MIME type, or
   *   'application/octet-stream' if no type cna be determined.
   */
  public static function getMimeType($uri, $mapping = NULL) {
    self::_debug("getMimeType({$uri}, {$mapping}) called.");

    // 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('.', 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 types matches, so return the default.
    return 'application/octet-stream';
  }

  /**
   * Sets the stream resource URI. URIs are formatted as "s3://key".
   *
   * @param string $uri
   *   A string containing the URI that should be used for this instance.
   */
  public function setUri($uri) {
    $this
      ->_debug("setUri({$uri}) called.");
    $this->uri = $uri;
  }

  /**
   * Returns the stream resource URI. URIs are formatted as "s3://key".
   *
   * @return string
   *   Returns the current URI of the instance.
   */
  public function getUri() {
    $this
      ->_debug("getUri() called for {$this->uri}.");
    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.
   *
   * @return string
   *   Returns a string containing a web accessible URL for the resource.
   */
  public function getExternalUrl() {
    $this
      ->_debug("getExternalUri() called for {$this->uri}.");
    $s3_filename = $this
      ->_uri_to_s3_filename($this->uri);

    // Image styles support:
    // If an image derivative URL (e.g. styles/thumbnail/blah.jpg) is generated
    // and 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.
    $path_parts = explode('/', $s3_filename);
    if ($path_parts[0] == 'styles') {
      if (!$this
        ->_s3fs_get_object($this->uri)) {

        // The style delivery path looks like: s3/files/styles/<style>/...
        // And $path_parts looks like array('styles', '<style>', ...), so
        // just add the extra parts to the beginning.
        array_unshift($path_parts, 's3', 'files');
        return url(implode('/', $path_parts), array(
          'absolute' => TRUE,
        ));
      }
    }

    // Set up the URL settings from the 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',
      ),
    );

    // 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_filename)) {
        $url_settings['presigned_url'] = TRUE;
        $url_settings['timeout'] = $timeout;
        break;
      }
    }

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

    // Throw a deprecation warning if any modules implement the old hook.
    // TODO: Remove this in April 2015, six months after the old hook was removed.
    if (module_implements('s3fs_url_info')) {
      trigger_error('hook_s3fs_url_info() is deprecated. Please use hook_s3fs_url_settings_alter() instead.', E_USER_DEPRECATED);
    }

    // Allow other modules to change the URL settings.
    drupal_alter('s3fs_url_settings', $url_settings, $s3_filename);
    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;
          }
        }
      }
      $url = $this->s3
        ->getObjectUrl($this->config['bucket'], $s3_filename, $expires, $url_settings['api_args']);
    }
    else {

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

    // 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'])) {
      $url .= (strpos($url, '?') === FALSE ? '?' : '&') . $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_filename)) {

          // You get a torrent URL by adding a "torrent" GET arg.
          $url .= (strpos($url, '?') === FALSE ? '?' : '&') . 'torrent';
          break;
        }
      }
    }
    return $url;
  }

  /**
   * Returns the local writable target of the resource within the stream.
   *
   * This function should be used in place of calls to realpath() or similar
   * functions when attempting to determine the location of a file. While
   * functions like realpath() may return the location of a read-only file, this
   * method may return a URI or path suitable for writing that is completely
   * separate from the URI used for reading.
   *
   * @param string $uri
   *   Optional URI.
   *
   * @return array
   *   Returns a string representing a location suitable for writing of a file,
   *   or FALSE if unable to write to the file such as with read-only streams.
   */
  protected function getTarget($uri = NULL) {
    $this
      ->_debug("getTarget({$uri}) called.");
    if (!isset($uri)) {
      $uri = $this->uri;
    }
    $data = explode('://', $uri, 2);

    // Remove erroneous leading or trailing forward-slashes and backslashes.
    return isset($data[1]) ? trim($data[1], '\\/') : FALSE;
  }

  /**
   * Gets the path that the wrapper is responsible for.
   *
   * @return string
   *   The empty string. Since this is a remote stream wrapper,
   *   it has no directory path.
   */
  public function getDirectoryPath() {
    $this
      ->_debug("getDirectoryPath() called.");
    return '';
  }

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

  /**
   * Changes permissions of the resource.
   *
   * This wrapper doesn't support the concept of filesystem permissions.
   *
   * @param int $mode
   *   Integer value for the permissions. Consult PHP chmod() documentation
   *   for more information.
   *
   * @return bool
   *   Returns TRUE.
   */
  public function chmod($mode) {
    $this
      ->_assert_constructor_called();
    $octal_mode = decoct($mode);
    $this
      ->_debug("chmod({$octal_mode}) called. S3fsStreamWrapper does not support this function.");
    return TRUE;
  }

  /**
   * This wrapper does not support realpath().
   *
   * @return bool
   *   Returns FALSE.
   */
  public function realpath() {
    $this
      ->_debug("realpath() called for {$this->uri}. S3fsStreamWrapper does not support this function.");
    return FALSE;
  }

  /**
   * Gets the name of the directory from 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
   *   A string containing the directory name, or FALSE if not applicable.
   *
   * @see drupal_dirname()
   */
  public function dirname($uri = NULL) {
    $this
      ->_debug("dirname({$uri}) called.");
    if (!isset($uri)) {
      $uri = $this->uri;
    }
    $target = $this
      ->getTarget($uri);
    $dirname = dirname($target);

    // When the dirname() call above is given 's3://', it returns '.'.
    // But 's3://.' is invalid, so we convert it to '' to get "s3://".
    if ($dirname == '.') {
      $dirname = '';
    }
    return "s3://{$dirname}";
  }

  /**
   * Support for fopen(), file_get_contents(), file_put_contents() etc.
   *
   * @param string $uri
   *   A string containing 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
   *   A string containing the path actually opened.
   *
   * @return bool
   *   Returns TRUE if file was opened successfully.
   *
   * @see http://php.net/manual/en/streamwrapper.stream-open.php
   */
  public function stream_open($uri, $mode, $options, &$opened_path) {
    $this
      ->_debug("stream_open({$uri}, {$mode}, {$options}, {$opened_path}) called.");
    $this->uri = $uri;

    // We don't care about the binary flag, so strip it out.
    $this->mode = $mode = rtrim($mode, 'bt');
    $this->params = $this
      ->_get_params($uri);
    $errors = array();
    if (strpos($mode, '+')) {
      $errors[] = t('The S3 File System stream wrapper does not allow simultaneous reading and writing.');
    }
    if (!in_array($mode, array(
      'r',
      'w',
      'a',
      'x',
    ))) {
      $errors[] = t("Mode not supported: %mode. Use one 'r', 'w', 'a', or 'x'.", array(
        '%mode' => $mode,
      ));
    }

    // When using mode "x", validate if the file exists first.
    if ($mode == 'x' && $this
      ->_read_cache('s3://' . $this->params['Key'])) {
      $errors[] = t("%uri already exists in your S3 bucket, so it cannot be opened with mode 'x'.", array(
        '%uri' => $uri,
      ));
    }
    if (!$errors) {
      if ($mode == 'r') {
        $this
          ->_open_read_stream($this->params, $errors);
      }
      elseif ($mode == 'a') {
        $this
          ->_open_append_stream($this->params, $errors);
      }
      else {
        $this
          ->_open_write_stream($this->params, $errors);
      }
    }
    return $errors ? $this
      ->_trigger_error($errors) : TRUE;
  }

  /**
   * Support for fclose().
   *
   * Clears the object buffer.
   *
   * @return bool
   *   TRUE
   *
   * @see http://php.net/manual/en/streamwrapper.stream-close.php
   */
  public function stream_close() {
    $this
      ->_debug("stream_close() called for {$this->params['Key']}.");
    $this->body = NULL;
    $this->params = NULL;
    return TRUE;
  }

  /**
   * This wrapper does not support flock().
   *
   * @return bool
   *   returns FALSE.
   *
   * @see http://php.net/manual/en/streamwrapper.stream-lock.php
   */
  public function stream_lock($operation) {
    $this
      ->_debug("stream_lock({$operation}) called. S3fsStreamWrapper doesn't support this function.");
    return FALSE;
  }

  /**
   * Support for fread(), file_get_contents() etc.
   *
   * @param int $count
   *   Maximum number of bytes to be read.
   *
   * @return string
   *   The string that was read, or FALSE in case of an error.
   *
   * @see http://php.net/manual/en/streamwrapper.stream-read.php
   */
  public function stream_read($count) {
    $this
      ->_debug("stream_read({$count}) called for {$this->uri}.");
    return $this->body
      ->read($count);
  }

  /**
   * Support for fwrite(), file_put_contents() etc.
   *
   * @param string $data
   *   The string to be written.
   *
   * @return int
   *   The number of bytes written.
   *
   * @see http://php.net/manual/en/streamwrapper.stream-write.php
   */
  public function stream_write($data) {
    $bytes = strlen($data);
    $this
      ->_debug("stream_write() called with {$bytes} bytes of data for {$this->uri}.");
    return $this->body
      ->write($data);
  }

  /**
   * Support for feof().
   *
   * @return bool
   *   TRUE if end-of-file has been reached.
   *
   * @see http://php.net/manual/en/streamwrapper.stream-eof.php
   */
  public function stream_eof() {
    $this
      ->_debug("stream_eof() called for {$this->params['Key']}.");
    return $this->body
      ->feof();
  }

  /**
   * Support for fseek().
   *
   * @param int $offset
   *   The byte offset to got to.
   * @param int $whence
   *   SEEK_SET, SEEK_CUR, or SEEK_END.
   *
   * @return bool
   *   TRUE on success.
   *
   * @see http://php.net/manual/en/streamwrapper.stream-seek.php
   */
  public function stream_seek($offset, $whence) {
    $this
      ->_debug("stream_seek({$offset}, {$whence}) called.");
    return $this->body
      ->seek($offset, $whence);
  }

  /**
   * Support for fflush(). Flush current cached stream data to a file in S3.
   *
   * @return bool
   *   TRUE if data was successfully stored (or there was no data to store).
   *
   * @see http://php.net/manual/en/streamwrapper.stream-flush.php
   */
  public function stream_flush() {
    $this
      ->_debug("stream_flush() called for {$this->params['Key']}.");
    if ($this->mode == 'r') {
      return FALSE;
    }

    // Prep the upload parameters.
    $this->body
      ->rewind();
    $upload_params = $this->params;
    $upload_params['Body'] = $this->body;

    // All files uploaded to S3 must be set to public-read, or users' browsers
    // will get PermissionDenied errors, and torrent URLs won't work.
    $upload_params['ACL'] = 'public-read';
    $upload_params['ContentType'] = S3fsStreamWrapper::getMimeType($this->uri);

    // Set the Cache-Control header, if the user specified one.
    if ($this->cacheControlHeader) {
      $upload_params['CacheControl'] = $this->cacheControlHeader;
    }

    // Allow other modules to change the upload params.
    drupal_alter('s3fs_upload_params', $upload_params);
    try {
      $this->s3
        ->putObject($upload_params);
      $this->s3
        ->waitUntilObjectExists($this->params);
      $metadata = $this
        ->_get_metadata_from_s3($this->uri);
      if ($metadata === FALSE) {

        // This should never happen, but just in case...
        throw new Exception(t('Uploading the file %file to S3 failed in an unexpected way.', array(
          '%file' => $this->uri,
        )));
      }
      $this
        ->_write_cache($metadata);
      clearstatcache(TRUE, $this->uri);
      return TRUE;
    } catch (\Exception $e) {
      $this
        ->_debug($e
        ->getMessage());
      return $this
        ->_trigger_error($e
        ->getMessage());
    }
  }

  /**
   * Support for ftell().
   *
   * @return int
   *   The current offset in bytes from the beginning of file.
   *
   * @see http://php.net/manual/en/streamwrapper.stream-tell.php
   */
  public function stream_tell() {
    $this
      ->_debug("stream_tell() called.");
    return $this->body
      ->ftell();
  }

  /**
   * Support for fstat().
   *
   * @return array
   *   An array with file status, or FALSE in case of an error - see fstat()
   *   for a description of this array.
   *
   * @see http://php.net/manual/en/streamwrapper.stream-stat.php
   */
  public function stream_stat() {
    $this
      ->_debug("stream_stat() called for {$this->params['Key']}.");
    $stat = fstat($this->body
      ->getStream());

    // Add the size of the underlying stream if it is known.
    if ($this->mode == 'r' && $this->body
      ->getSize()) {
      $stat[7] = $stat['size'] = $this->body
        ->getSize();
    }
    return $stat;
  }

  /**
   * Cast the stream to return the underlying file resource
   *
   * @param int $cast_as
   *   STREAM_CAST_FOR_SELECT or STREAM_CAST_AS_STREAM
   *
   * @return resource
   */
  public function stream_cast($cast_as) {
    $this
      ->_debug("stream_cast({$cast_as}) called.");
    return $this->body
      ->getStream();
  }

  /**
   * Support for unlink().
   *
   * @param string $uri
   *   A string containing the uri to 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
      ->_assert_constructor_called();
    $this
      ->_debug("unlink({$uri}) called.");
    try {
      $this->s3
        ->deleteObject($this
        ->_get_params($uri));
      $this
        ->_delete_cache($uri);
      clearstatcache(TRUE, $uri);
      return TRUE;
    } catch (\Exception $e) {
      $this
        ->_debug($e
        ->getMessage());
      return $this
        ->_trigger_error($e
        ->getMessage());
    }
  }

  /**
   * Support for rename().
   *
   * If $to_uri exists, this file will be overwritten. This behavior is
   * identical to the PHP rename() function.
   *
   * @param string $from_uri
   *   The uri to the file to rename.
   * @param string $to_uri
   *   The new uri for file.
   *
   * @return bool
   *   TRUE if file was successfully renamed.
   *
   * @see http://php.net/manual/en/streamwrapper.rename.php
   */
  public function rename($from_uri, $to_uri) {
    $this
      ->_assert_constructor_called();
    $this
      ->_debug("rename({$from_uri}, {$to_uri}) called.");
    $from_params = $this
      ->_get_params($from_uri);
    $to_params = $this
      ->_get_params($to_uri);
    clearstatcache(TRUE, $from_uri);
    clearstatcache(TRUE, $to_uri);

    // Add the copyObject() parameters.
    $to_params['CopySource'] = "/{$from_params['Bucket']}/" . rawurlencode($from_params['Key']);
    $to_params['MetadataDirective'] = 'COPY';
    $to_params['ACL'] = 'public-read';
    try {

      // Copy the original object to the specified destination.
      $this->s3
        ->copyObject($to_params);

      // Copy the original object's metadata.
      $metadata = $this
        ->_read_cache($from_uri);
      $metadata['uri'] = $to_uri;
      $this
        ->_write_cache($metadata);

      // We need to wait because of S3's "eventual consistency".
      $wait_params = $this
        ->_get_params($to_uri);
      $wait_params['waiter.interval'] = 2;
      $this->s3
        ->waitUntilObjectExists($wait_params);

      // Now that we know the new object is there, delete the old one.
      return $this
        ->unlink($from_uri);
    } catch (\Exception $e) {
      $this
        ->_debug($e
        ->getMessage());
      return $this
        ->_trigger_error($e
        ->getMessage());
    }
  }

  /**
   * Support for mkdir().
   *
   * @param string $uri
   *   A string containing 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 directory was successfully created.
   *
   * @see http://php.net/manual/en/streamwrapper.mkdir.php
   */
  public function mkdir($uri, $mode, $options) {
    $this
      ->_assert_constructor_called();
    $this
      ->_debug("mkdir({$uri}, {$mode}, {$options}) called.");
    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 anything in S3.
    $metadata = _s3fs_convert_metadata($uri, array());
    $metadata['timestamp'] = date('U', time());
    $this
      ->_write_cache($metadata);

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

  /**
   * Support for rmdir().
   *
   * @param string $uri
   *   A string containing 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) {
    $this
      ->_assert_constructor_called();
    $this
      ->_debug("rmdir({$uri}, {$options}) called.");
    if (!$this
      ->_uri_is_dir($uri)) {
      return FALSE;
    }

    // We need a version of the URI with no / (folders are cached with no /),
    // and a version with the /, in case it's an object in S3, and to
    // differentiate it from files with this folder's name as a substring.
    // e.g. rmdir('s3://foo/bar') should ignore s3://foo/barbell.jpg.
    $bare_uri = rtrim($uri, '/');
    $slash_uri = $bare_uri . '/';

    // Check if the folder is empty.
    $files = db_select('s3fs_file', 's')
      ->fields('s')
      ->condition('uri', db_like($slash_uri) . '%', 'LIKE')
      ->execute()
      ->fetchAll(PDO::FETCH_ASSOC);

    // If the folder is empty, it's eligible for deletion.
    if (empty($files)) {
      $result = $this
        ->_delete_cache($bare_uri);
      clearstatcache(TRUE, $bare_uri);

      // Also delete the object from S3, if it's there.
      $params = $this
        ->_get_params($slash_uri);
      try {
        if ($this->s3
          ->doesObjectExist($params['Bucket'], $params['Key'])) {
          $this->s3
            ->deleteObject($params);
        }
      } catch (\Exception $e) {
        $this
          ->_debug($e
          ->getMessage());
        return $this
          ->_trigger_error($e
          ->getMessage());
      }
      return (bool) $result;
    }

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

  /**
   * Support for stat().
   *
   * @param string $uri
   *   A string containing the URI to get information about.
   * @param int $flags
   *   A bit mask of STREAM_URL_STAT_LINK and STREAM_URL_STAT_QUIET.
   *   S3fsStreamWrapper ignores this value.
   *
   * @return array
   *   An array with file status, or FALSE in case of an error - see fstat()
   *   for a description of this array.
   *
   * @see http://php.net/manual/en/streamwrapper.url-stat.php
   */
  public function url_stat($uri, $flags) {
    $this
      ->_assert_constructor_called();
    $this
      ->_debug("url_stat({$uri}, {$flags}) called.");
    return $this
      ->_stat($uri);
  }

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

    // If this URI was originally s3://, the above code removed *both* slashes
    // but only added one back. So we need to add back the second slash.
    if ($slash_uri == 's3:/') {
      $slash_uri = 's3://';
    }

    // Get the list of uris for files and folders which are children of the
    // specified folder, but not grandchildren.
    $and = db_and();
    $and
      ->condition('uri', db_like($slash_uri) . '%', 'LIKE');
    $and
      ->condition('uri', db_like($slash_uri) . '%/%', 'NOT LIKE');
    $child_uris = db_select('s3fs_file', 's')
      ->fields('s', array(
      'uri',
    ))
      ->condition($and)
      ->execute()
      ->fetchCol(0);
    $this->dir = array();
    foreach ($child_uris as $child_uri) {
      $this->dir[] = basename($child_uri);
    }
    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() {
    $this
      ->_debug("dir_readdir() called.");
    $entry = each($this->dir);
    return $entry ? $entry['value'] : FALSE;
  }

  /**
   * Support for rewinddir().
   *
   * @return bool
   *   TRUE on success.
   *
   * @see http://php.net/manual/en/streamwrapper.dir-rewinddir.php
   */
  public function dir_rewinddir() {
    $this
      ->_debug("dir_rewinddir() called.");
    reset($this->dir);
    return TRUE;
  }

  /**
   * Support for closedir().
   *
   * @return bool
   *   TRUE on success.
   *
   * @see http://php.net/manual/en/streamwrapper.dir-closedir.php
   */
  public function dir_closedir() {
    $this
      ->_debug("dir_closedir() called.");
    unset($this->dir);
    return TRUE;
  }

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

  /**
   * Convert a URI into a valid S3 filename.
   */
  protected function _uri_to_s3_filename($uri) {
    $filename = str_replace('s3://', '', $uri);

    // Remove both leading and trailing /s. S3 filenames never start with /,
    // and a $uri for a folder might be specified with a trailing /, which
    // we'd need to remove to be able to retrieve it from the cache.
    return trim($filename, '/');
  }

  /**
   * Get the status of the file with the specified URI.
   *
   * @return array
   *   An array with file status, or FALSE if the file doesn't exist.
   *   See fstat() for a description of this array.
   *
   * @see http://php.net/manual/en/streamwrapper.stream-stat.php
   */
  protected function _stat($uri) {
    $this
      ->_debug("_stat({$uri}) called.", TRUE);
    $metadata = $this
      ->_s3fs_get_object($uri);
    if ($metadata) {
      $stat = array();
      $stat[0] = $stat['dev'] = 0;
      $stat[1] = $stat['ino'] = 0;
      $stat[2] = $stat['mode'] = $metadata['mode'];
      $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'] = $metadata['uid'];
        $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;
  }

  /**
   * Determine whether the $uri is a directory.
   *
   * @param string $uri
   *   A string containing the uri to the resource to check. If none is given
   *   defaults to $this->uri
   *
   * @return bool
   *   TRUE if the resource is a directory
   */
  protected function _uri_is_dir($uri) {
    if ($uri == 's3://' || $uri == 's3:') {
      return TRUE;
    }

    // Folders only exist in the cache, so we don't need to query S3.
    // Since they're stored with no ending slash, so we need to trim it.
    $uri = rtrim($uri, '/');
    $metadata = $this
      ->_read_cache($uri);
    return $metadata ? $metadata['dir'] : FALSE;
  }

  /**
   * Try to fetch an object from the metadata cache.
   *
   * If that file isn't in the cache, we assume it doesn't exist.
   *
   * @param string $uri
   *   A string containing the uri of the resource to check.
   *
   * @return bool
   *   An array if the $uri exists, otherwise FALSE.
   */
  protected function _s3fs_get_object($uri) {
    $this
      ->_debug("_s3fs_get_object({$uri}) called.", TRUE);

    // For the root directory, just return metadata for a generic folder.
    if ($uri == 's3://' || $uri == 's3:') {
      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) {
        $this
          ->_debug($e
          ->getMessage());
        return $this
          ->_trigger_error($e
          ->getMessage());
      }
    }
    return $metadata;
  }

  /**
   * Fetch an object from the file metadata cache table.
   *
   * @param string $uri
   *   A string containing the uri of the resource to check.
   *
   * @return array
   *   An array of metadata if the $uri is in the cache, otherwise FALSE.
   */
  protected function _read_cache($uri) {
    $this
      ->_debug("_read_cache({$uri}) called.", TRUE);
    $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 's3://'.
   *     '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.
   *     'mode' => The octal mode of the file.
   *     'uid' => The uid of the owner of the S3 object.
   *
   * @throws
   *   Exceptions which occur in the database call will percolate.
   */
  protected function _write_cache($metadata) {
    $this
      ->_debug("_write_cache({$metadata['uri']}) called.", TRUE);
    db_merge('s3fs_file')
      ->key(array(
      'uri' => $metadata['uri'],
    ))
      ->fields($metadata)
      ->execute();
    $dirname = $this
      ->dirname($metadata['uri']);
    if ($dirname != 's3://') {
      $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.
   *
   * @throws
   *   Exceptions which occur in the database call will percolate.
   */
  protected function _delete_cache($uri) {
    $this
      ->_debug("_delete_cache({$uri}) called.", TRUE);
    $delete_query = db_delete('s3fs_file');
    if (is_array($uri)) {

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

  /**
   * Get the stream context options available to the current stream.
   *
   * @return array
   */
  protected function _get_options() {
    $context = isset($this->context) ? $this->context : stream_context_get_default();
    $options = stream_context_get_options($context);
    return isset($options['s3']) ? $options['s3'] : array();
  }

  /**
   * Get a specific stream context option
   *
   * @param string $name Name of the option to retrieve
   *
   * @return mixed|null
   */
  protected function _get_option($name) {
    $options = $this
      ->_get_options();
    return isset($options[$name]) ? $options[$name] : NULL;
  }

  /**
   * Get the Command parameters for the specified URI.
   *
   * @param string $uri
   *   The URI of the file.
   *
   * @return array
   *   A Command parameters array, including 'Bucket', 'Key', and
   *   context params.
   */
  protected function _get_params($uri) {
    $params = $this
      ->_get_options();
    unset($params['seekable']);
    unset($params['throw_exceptions']);
    $params['Bucket'] = $this->config['bucket'];

    // Strip s3:// from the URI to get the S3 Key.
    $params['Key'] = $this
      ->_uri_to_s3_filename($uri);
    return $params;
  }

  /**
   * Initialize the stream wrapper for a read only stream.
   *
   * @param array $params
   *   A Command parameters array.
   * @param array $errors
   *   Array to which encountered errors should be appended.
   *
   * @return bool
   */
  protected function _open_read_stream($params, &$errors) {
    $this
      ->_debug("_open_read_stream({$params['Key']}) called.", TRUE);

    // Create the command and serialize the request.
    $request = $this
      ->_get_signed_request($this->s3
      ->getCommand('GetObject', $params));

    // Create a stream that uses the EntityBody object.
    $factory = $this
      ->_get_option('stream_factory');
    if (empty($factory)) {
      $factory = new Guzzle\Stream\PhpStreamRequestFactory();
    }
    $this->body = $factory
      ->fromRequest($request, array(), array(
      'stream_class' => 'Guzzle\\Http\\EntityBody',
    ));

    // Wrap the body in an S3fsSeekableCachingEntityBody, so that seeks can
    // go to not-yet-read sections of the file.
    if (class_exists('S3fsSeekableCachingEntityBody')) {
      $this->body = new S3fsSeekableCachingEntityBody($this->body);
    }
    return TRUE;
  }

  /**
   * Initialize the stream wrapper for an append stream.
   *
   * @param array $params
   *   A Command parameters array.
   * @param array $errors
   *   Array to which encountered errors should be appended.
   *
   * @return bool
   */
  protected function _open_append_stream($params, &$errors) {
    $this
      ->_debug("_open_append_stream({$params['Key']}) called.", TRUE);
    try {

      // Get the body of the object
      $this->body = $this->s3
        ->getObject($params)
        ->get('Body');
      $this->body
        ->seek(0, SEEK_END);
    } catch (Aws\S3\Exception\S3Exception $e) {

      // The object does not exist, so use a simple write stream.
      $this
        ->_open_write_stream($params, $errors);
    }
    return true;
  }

  /**
   * Initialize the stream wrapper for a write only stream.
   *
   * @param array $params
   *   A Command parameters array.
   * @param array $errors
   *   Array to which encountered errors should be appended.
   *
   * @return bool
   */
  protected function _open_write_stream($params, &$errors) {
    $this
      ->_debug("_open_write_stream({$params['Key']}) called.", TRUE);
    $this->body = new Guzzle\Http\EntityBody(fopen('php://temp', 'r+'));
  }

  /**
   * Serialize and sign a command, returning a request object
   *
   * @param CommandInterface $command Command to sign
   *
   * @return RequestInterface
   */
  protected function _get_signed_request($command) {
    $this
      ->_debug("_get_signed_request() called.", TRUE);
    $request = $command
      ->prepare();
    $request
      ->dispatch('request.before_send', array(
      'request' => $request,
    ));
    return $request;
  }

  /**
   * Returns the converted metadata for an object in S3.
   *
   * @param string $uri
   *   The URI for the object in S3.
   *
   * @return array
   *   An array of DB-compatible file metadata.
   *
   * @throws \Exception
   *   Any exception raised by the listObjects() S3 command will percolate
   *   out of this function.
   */
  function _get_metadata_from_s3($uri) {
    $this
      ->_debug("_get_metadata_from_s3({$uri}) called.", TRUE);
    $params = $this
      ->_get_params($uri);

    // TODO: Get rid of the uid column in the database. It's not used for
    // anything, and populating it requires us to use listObjects when
    // headObject alone would be sufficient.
    // In addition, the node column appears to also be entirely unused.
    // It may be worth removing that, too.
    // I wish we could call headObject(), rather than listObjects(), but
    // headObject() doesn't return the object's owner ID.
    $list_objects_result = $this->s3
      ->listObjects(array(
      'Bucket' => $params['Bucket'],
      'Prefix' => $params['Key'],
      'MaxKeys' => 1,
    ));

    // $list_objects_result['Contents'][0] is the s3 metadata. If it's unset,
    // there is no file in S3 matching this URI.
    if (empty($list_objects_result['Contents'][0])) {
      return FALSE;
    }
    $result = $list_objects_result['Contents'][0];

    // Neither headObject() nor listObjects() returns everything we need, so
    // to get the version ID of the file, we need to call both.
    $head_object_result = $this->s3
      ->headObject(array(
      'Bucket' => $params['Bucket'],
      'Key' => $params['Key'],
    ));
    if (!empty($head_object_result['VersionId'])) {
      $result['VersionId'] = $head_object_result['VersionId'];
    }
    return _s3fs_convert_metadata($uri, $result);
  }

  /**
   * 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) {
    $wait_params = $this
      ->_get_params($uri);

    // Retry ten times, once every second.
    $wait_params['waiter.max_attempts'] = 10;
    $wait_params['waiter.interval'] = 1;
    try {
      $this->s3
        ->waitUntilObjectExists($wait_params);
    } catch (Aws\Common\Exception\RuntimeException $e) {
      return FALSE;
    }
    return TRUE;
  }

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

  /**
   * Call the constructor it it hasn't been has been called yet.
   *
   * Due to PHP bug #40459, the constructor of this class isn't always called
   * for some of the methods.
   *
   * @see https://bugs.php.net/bug.php?id=40459
   */
  protected function _assert_constructor_called() {
    if ($this->constructed === FALSE) {
      $this
        ->__construct();
    }
  }

  /**
   * Logging function used for debugging.
   *
   * This function only writes anything if the global variable $_s3fs_debug
   * is TRUE.
   *
   * @param string $msg
   *   The message to debug log.
   * @param bool $internal
   *   If this is TRUE, don't log $msg unless $_s3fs_debug_internal is TRUE.
   */
  protected static function _debug($msg, $internal = FALSE) {
    global $_s3fs_debug, $_s3fs_debug_internal;
    if ($_s3fs_debug && (!$internal || $_s3fs_debug_internal)) {
      debug($msg);
    }
  }

}

// Guzzle\Http\CachingEntityBody is only defined once the SDK has been loaded,
// so we need to load it before we can inherit from it.
$library = _s3fs_load_awssdk2_library();
if ($library['loaded']) {

  /**
   * A replacement class for CachingEntityBody that serves better for s3fs.
   *
   * Any instantiation of this class must be wrapped in a check for its
   * existence, since it may not be defined under certain circumstances.
   */
  class S3fsSeekableCachingEntityBody extends Guzzle\Http\CachingEntityBody {

    /**
     * This version of seek() allows seeking past the end of the cache.
     *
     * If the caller attempts to seek more than 50 megs into the file,
     * though, an exception will be thrown, because that would take up too
     * much memory.
     */
    public function seek($offset, $whence = SEEK_SET) {
      if ($whence == SEEK_SET) {
        $byte = $offset;
      }
      else {
        if ($whence == SEEK_CUR) {
          $byte = $offset + $this
            ->ftell();
        }
        else {
          throw new RuntimeException(__CLASS__ . ' supports only SEEK_SET and SEEK_CUR seek operations');
        }
      }
      if ($byte > 52428800) {
        throw new RuntimeException("Seeking more than 50 megabytes into a remote file is not supported, due to memory constraints.\n            If you need to bypass this error, please contact the maintainers of S3 File System.");
      }

      // If the caller tries to seek past the end of the currently cached
      // data, read in enough of the remote stream to let the seek occur.
      while ($byte > $this->body
        ->getSize() && !$this
        ->isConsumed()) {
        $this
          ->read(16384);
      }
      return $this->body
        ->seek($byte);
    }

  }
}

Classes

Namesort descending Description
S3fsStreamWrapper The stream wrapper class.