You are here

StreamWrapper.php in AmazonS3 7.2

Drupal stream wrapper implementation for Amazon S3

Implements DrupalStreamWrapperInterface to provide an Amazon S3 wrapper with the s3:// prefix.

Namespace

Drupal\amazons3

File

src/StreamWrapper.php
View source
<?php

namespace Drupal\amazons3;

use Doctrine\Common\Cache\ArrayCache;
use Doctrine\Common\Cache\ChainCache;
use Drupal\amazons3\Matchable\BasicPath;
use Drupal\amazons3\Matchable\PresignedPath;
use Guzzle\Cache\DoctrineCacheAdapter;
use Aws\S3\S3Client as AwsS3Client;
use Guzzle\Http\Mimetypes;
use Guzzle\Http\Url;

/**
 * @file
 * Drupal stream wrapper implementation for Amazon S3
 *
 * Implements DrupalStreamWrapperInterface to provide an Amazon S3 wrapper with
 * the s3:// prefix.
 */
class StreamWrapper extends \Aws\S3\StreamWrapper implements \DrupalStreamWrapperInterface {
  use DrupalAdapter\Common;
  use DrupalAdapter\FileMimetypes;

  /**
   * The base domain of S3.
   *
   * @const string
   */
  const S3_API_DOMAIN = 's3.amazonaws.com';

  /**
   * The path to the image style generation callback.
   *
   * If this is changed, be sure to update amazons3_menu() as well.
   *
   * @const string
   */
  const stylesCallback = 'amazons3/image-derivative';

  /**
   * The name of the S3Client class to use.
   *
   * @var string
   */
  protected static $s3ClientClass = '\\Drupal\\amazons3\\S3Client';

  /**
   * Default configuration used when constructing a new stream wrapper.
   *
   * @var \Drupal\amazons3\StreamWrapperConfiguration
   */
  protected static $defaultConfig;

  /**
   * Configuration for this stream wrapper.
   *
   * @var \Drupal\amazons3\StreamWrapperConfiguration
   */
  protected $config;

  /**
   * Instance URI referenced as "s3://bucket/key"
   *
   * @var S3Url
   */
  protected $uri;

  /**
   * The URL associated with the S3 object.
   *
   * @var S3URL
   */
  protected $s3Url;

  /**
   * Set default configuration to use when constructing a new stream wrapper.
   *
   * @param \Drupal\amazons3\StreamWrapperConfiguration $config
   */
  public static function setDefaultConfig(StreamWrapperConfiguration $config) {
    static::$defaultConfig = $config;
  }

  /**
   * Return the default configuration.
   *
   * @return \Drupal\amazons3\StreamWrapperConfiguration
   */
  public static function getDefaultConfig() {
    return static::$defaultConfig;
  }

  /**
   * Set the name of the S3Client class to use.
   *
   * @param string $client
   */
  public static function setS3ClientClass($client) {
    static::$s3ClientClass = $client;
  }

  /**
   * Construct a new stream wrapper.
   *
   * @param \Drupal\amazons3\StreamWrapperConfiguration $config
   *   (optional) A specific configuration to use for this wrapper.
   */
  public function __construct(StreamWrapperConfiguration $config = NULL) {
    if (!$config) {
      if (static::$defaultConfig) {
        $config = static::$defaultConfig;
      }
      else {

        // @codeCoverageIgnoreStart
        $config = StreamWrapperConfiguration::fromDrupalVariables();

        // @codeCoverageIgnoreEnd
      }
    }
    $this->config = $config;
    if (!$this
      ->getClient()) {

      /** @var S3Client $name */
      $name = static::$s3ClientClass;
      $this
        ->setClient($name::factory(array(
        'region' => $this->config
          ->getRegion(),
      )));
    }
    if ($this->config
      ->isCaching() && !static::$cache) {
      static::attachCache(new DoctrineCacheAdapter(new ChainCache([
        new ArrayCache(),
        new Cache(),
      ])), $this->config
        ->getCacheLifetime());
    }
  }

  /**
   * Get the client associated with this stream wrapper.
   *
   * @return \Aws\S3\S3Client
   */
  public static function getClient() {
    return self::$client;
  }

  /**
   * Set the client associated with this stream wrapper.
   *
   * Note that all stream wrapper instances share a global client.
   *
   * @param \Aws\S3\S3Client $client
   *   The client to use. Set to NULL to remove an existing client.
   */
  public static function setClient(AwsS3Client $client = NULL) {
    self::$client = $client;
  }

  /**
   * Support for flock().
   *
   * S3 has no support for file locking. If it's needed, it has to be
   * implemented at the application layer.
   *
   * @link https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectPUT.html
   *
   * @param string $operation
   *   One of the following:
   *   - LOCK_SH to acquire a shared lock (reader).
   *   - LOCK_EX to acquire an exclusive lock (writer).
   *   - LOCK_UN to release a lock (shared or exclusive).
   *   - LOCK_NB if you don't want flock() to block while locking (not
   *     supported on Windows).
   *
   * @return bool
   *   returns TRUE if lock was successful
   *
   * @link http://php.net/manual/en/streamwrapper.stream-lock.php
   *
   * @codeCoverageIgnore
   */
  public function stream_lock($operation) {
    return FALSE;
  }

  /**
   * {@inheritdoc}
   */
  function setUri($uri) {

    // file_stream_wrapper_get_instance_by_scheme() assumes that all schemes
    // can work without a directory, but S3 requires a bucket. If a raw scheme
    // is passed in, we append our default bucket.
    if ($uri == 's3://') {
      $uri = 's3://' . $this->config
        ->getBucket();
    }
    $this->uri = S3Url::factory($uri);
  }

  /**
   * {@inheritdoc}
   */
  public function getUri() {
    return (string) $this->uri;
  }

  /**
   * {@inheritdoc}
   */
  public function getExternalUrl() {
    if (!isset($this->uri)) {
      throw new \LogicException('A URI must be set before calling getExternalUrl().');
    }
    $path_segments = $this->uri
      ->getPathSegments();
    $args = array();

    // Image styles support
    // Delivers the first request to an image from the private file system
    // otherwise it returns an external URL to an image that has not been
    // created yet.
    if (!empty($path_segments) && $path_segments[0] === 'styles' && !file_exists((string) $this->uri)) {
      return $this
        ->url($this::stylesCallback . '/' . $this->uri
        ->getBucket() . $this->uri
        ->getPath(), array(
        'absolute' => TRUE,
      ));
    }

    // UI overrides.
    // Save as.
    $expiry = NULL;
    if ($this
      ->forceDownload()) {
      $args['ResponseContentDisposition'] = $this
        ->getContentDispositionAttachment();
      $expiry = time() + 60 * 60 * 24;
    }

    // Torrent URLs.
    $path = $this
      ->getLocalPath();
    if ($this
      ->useTorrent()) {
      $path .= '?torrent';
    }
    if ($presigned = $this
      ->usePresigned()) {
      $expiry = time() + $presigned
        ->getTimeout();
    }

    // @codeCoverageIgnoreStart
    if ($expiry && $this->config
      ->isCloudFront()) {
      $url = $this
        ->getCloudFrontUrl($path, $expiry);
    }
    else {
      $args['Scheme'] = $this->config
        ->getDomainScheme();

      // Generate a standard URL.
      $url = $this
        ->getS3Url($path, $expiry, $args);
    }
    return (string) $url;
  }

  /**
   * {@inheritdoc}
   */
  public static function getMimeType($uri, $mapping = NULL) {

    // Load the default file map.
    // @codeCoverageIgnoreStart
    if (!$mapping) {
      $mapping = static::file_mimetype_mapping();
    }

    // @codeCoverageIgnoreEnd
    $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, and
    // 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]];
      }

      // @codeCoverageIgnoreStart
    }

    // @codeCoverageIgnoreEnd
    return 'application/octet-stream';
  }

  /**
   * {@inheritdoc}
   *
   * @codeCoverageIgnore
   */
  public function chmod($mode) {

    // TODO: Implement chmod() method.
    return TRUE;
  }

  /**
   * @return bool
   *   FALSE, as this stream wrapper does not support realpath().
   *
   * @codeCoverageIgnore
   */
  public function realpath() {
    return FALSE;
  }

  /**
   * {@inheritdoc}
   */
  public function dirname($uri = NULL) {

    // drupal_dirname() doesn't call setUri() before calling. That lead our URI
    // to be stuck at the default 's3://'' that is set by
    // file_stream_wrapper_get_instance_by_scheme().
    if ($uri) {
      $this
        ->setUri($uri);
    }
    $s3url = S3Url::factory($uri, $this->config);
    $s3url
      ->normalizePath();
    $pathSegments = $s3url
      ->getPathSegments();
    array_pop($pathSegments);
    $s3url
      ->setPath($pathSegments);
    return trim((string) $s3url, '/');
  }

  /**
   * Return the local filesystem path.
   *
   * @return string
   *   The local path.
   */
  protected function getLocalPath() {
    $path = $this->uri
      ->getPath();
    $path = trim($path, '/');
    return $path;
  }

  /**
   * Override register() to force using hook_stream_wrappers().
   *
   * @param \Aws\S3\S3Client $client
   */
  public static function register(AwsS3Client $client) {
    throw new \LogicException('Drupal handles registration of stream wrappers. Implement hook_stream_wrappers() instead.');
  }

  /**
   * Override getOptions() to default all files to be publicly readable.
   *
   * @return array
   */
  public function getOptions() {
    if (!isset($this->uri)) {
      throw new \LogicException('A URI must be set before calling getOptions().');
    }
    $options = parent::getOptions();
    $options['ACL'] = 'public-read';
    if ($this
      ->useRrs()) {
      $options['StorageClass'] = 'REDUCED_REDUNDANCY';
    }
    return $options;
  }

  /**
   * Return the basename for this URI.
   *
   * @return string
   *   The basename of the URI.
   */
  public function getBasename() {
    if (!isset($this->uri)) {
      throw new \LogicException('A URI must be set before calling getBasename().');
    }
    return basename($this
      ->getLocalPath());
  }

  /**
   * Return a string to use as a Content-Disposition header.
   *
   * @return string
   *   The header value.
   */
  protected function getContentDispositionAttachment() {

    // Encode the filename according to RFC2047.
    return 'attachment; filename="' . mb_encode_mimeheader($this
      ->getBasename()) . '"';
  }

  /**
   * Find if this URI should force a download.
   *
   * @return BasicPath|bool
   *   The BasicPath if the local path of the stream URI should force a
   *   download, FALSE otherwise.
   */
  protected function forceDownload() {
    return $this->config
      ->getSaveAsPaths()
      ->match($this
      ->getLocalPath());
  }

  /**
   * Find if the URL should be returned as a torrent.
   *
   * @return BasicPath|bool
   *   The BasicPath if a torrent should be served, FALSE otherwise.
   */
  protected function useTorrent() {
    return $this->config
      ->getTorrentPaths()
      ->match($this
      ->getLocalPath());
  }

  /**
   * Find if the URL should be presigned.
   *
   * @return PresignedPath|bool
   *   The matching PresignedPath if a presigned URL should be served, FALSE
   *   otherwise.
   */
  protected function usePresigned() {
    return $this->config
      ->getPresignedPaths()
      ->match($this
      ->getLocalPath());
  }

  /**
   * Find if the URL should be saved to Reduced Redundancy Storage.
   *
   * @return PresignedPath|bool
   *   The matching PresignedPath if a presigned URL should be served, FALSE
   *   otherwise.
   */
  protected function useRrs() {
    return $this->config
      ->getReducedRedundancyPaths()
      ->match($this
      ->getLocalPath());
  }

  /**
   * Replace the host in a URL with the configured domain.
   *
   * @param Url $url
   *   The URL to modify.
   */
  protected function injectCname($url) {
    if (strpos($url
      ->getHost(), $this->config
      ->getDomain()) === FALSE) {
      $url
        ->setHost($this->config
        ->getDomain());
    }
  }

  /**
   * Get a CloudFront URL for an S3 key.
   *
   * @param $key
   *   The S3 object key.
   * @param int $expiry
   *   (optional) Expiry time for the URL, as a Unix timestamp.
   * @return \Guzzle\Http\Url
   *   The CloudFront URL.
   */
  protected function getCloudFrontUrl($key, $expiry = NULL) {

    // Real CloudFront credentials are required to test this, so we ignore
    // testing this.
    // @codeCoverageIgnoreStart
    $cf = $this->config
      ->getCloudFront();
    $url = new Url('https', $this->config
      ->getDomain());
    $url
      ->setPath($key);
    $this
      ->injectCname($url);
    $options = array(
      'url' => (string) $url,
      'expires' => $expiry,
    );
    $url = Url::factory($cf
      ->getSignedUrl($options));
    return $url;

    // @codeCoverageIgnoreEnd
  }

  /**
   * Get a regular S3 URL for a key.
   *
   * @param string $key
   *   The S3 object key.
   * @param int $expiry
   *   (optional) Expiry time for the URL, as a Unix timestamp.
   * @param array $args
   *   (optional) Array of additional arguments to pass to getObjectUrl().
   *
   * @return \Guzzle\Http\Url
   *   An https URL to access the key.
   */
  protected function getS3Url($key, $expiry = NULL, array $args = array()) {
    $url = Url::factory(static::$client
      ->getObjectUrl($this->uri
      ->getBucket(), $key, $expiry, $args));
    $this
      ->injectCname($url);
    return $url;
  }

  /**
   * {@inheritdoc}
   */
  public function unlink($path) {
    $this
      ->setUri($path);
    return parent::unlink($path);
  }

  /**
   * {@inheritdoc}
   */
  public function mkdir($path, $mode, $options) {
    $this
      ->setUri($path);
    return parent::mkdir($path, $mode, $options);
  }

  /**
   * {@inheritdoc}
   */
  public function stream_open($path, $mode, $options, &$opened_path) {
    $this
      ->setUri($path);
    return parent::stream_open($path, $mode, $options, $opened_path);
  }

  /**
   * {@inheritdoc}
   */
  public function url_stat($path, $flags) {
    $this
      ->setUri($path);
    return parent::url_stat($path, $flags);
  }

  /**
   * {@inheritdoc}
   */
  public function rmdir($path, $options) {
    $this
      ->setUri($path);
    return parent::rmdir($path, $options);
  }

  /**
   * {@inheritdoc}
   */
  public function dir_opendir($path, $options) {
    $this
      ->setUri($path);
    return parent::dir_opendir($path, $options);
  }

  /**
   * Override rename() to handle setting the URI on move.
   *
   * {@inheritdoc}
   */
  public function rename($path_from, $path_to) {
    $this
      ->setUri($path_from);
    $partsFrom = $this
      ->getParams($path_from);
    $this
      ->setUri($path_to);
    $partsTo = $this
      ->getParams($path_to);

    // We ignore testing this block since it is copied directly from the parent
    // method which is covered by the AWS SDK tests.
    // @codeCoverageIgnoreStart
    $this
      ->clearStatInfo($path_from);
    $this
      ->clearStatInfo($path_to);
    if (!$partsFrom['Key'] || !$partsTo['Key']) {
      return $this
        ->triggerError('The Amazon S3 stream wrapper only supports copying objects');
    }
    try {

      // Copy the object and allow overriding default parameters if desired, but by default copy metadata
      static::$client
        ->copyObject($this
        ->getOptions() + array(
        'Bucket' => $partsTo['Bucket'],
        'Key' => $partsTo['Key'],
        'CopySource' => '/' . $partsFrom['Bucket'] . '/' . rawurlencode($partsFrom['Key']),
        'MetadataDirective' => 'COPY',
      ));

      // Delete the original object
      static::$client
        ->deleteObject(array(
        'Bucket' => $partsFrom['Bucket'],
        'Key' => $partsFrom['Key'],
      ) + $this
        ->getOptions());
    } catch (\Exception $e) {
      return $this
        ->triggerError($e
        ->getMessage());
    }

    // @codeCoverageIgnoreEnd
    return true;
  }

}

Classes

Namesort descending Description
StreamWrapper @file Drupal stream wrapper implementation for Amazon S3