brandfolder-8.x-1.x-dev/src/StreamWrapper/BrandfolderStreamWrapper.php
src/StreamWrapper/BrandfolderStreamWrapper.php
<?php
namespace Drupal\brandfolder\StreamWrapper;
use Drupal\Core\StreamWrapper\StreamWrapperInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Database\Connection;
use Drupal\Core\Url;
use Drupal\image\Entity\ImageStyle;
use Psr\Log\LoggerInterface;
use Drupal\brandfolder\File\MimeType\BrandfolderMimeTypeHandler;
/**
* Drupal stream wrapper implementation for Brandfolder.
*
* Implements StreamWrapperInterface to provide a Brandfolder wrapper
* tied to the "bf://" scheme.
*
* @ingroup brandfolder
*/
class BrandfolderStreamWrapper implements StreamWrapperInterface {
use StringTranslationTrait;
/**
* Base URL.
*
* @var string
*/
protected $baseUrl = NULL;
/**
* Instance URI (stream).
*
* A stream is referenced as "scheme://target".
*
* @var string
*/
protected $uri;
/**
* Filemime mapping.
*
* @var array
* Default map for determining filemime types.
*/
protected static $mimeTypeMapping = NULL;
/**
* The database connection.
*
* @var Connection
*/
protected $connection;
/**
* Drupal logger.
*
* @var LoggerInterface $logger
*/
protected $logger;
/**
* @inheritDoc
*/
public static function getType(): int {
return StreamWrapperInterface::NORMAL;
}
/**
* Class constructor.
*
* @todo: The following code doesn't seem to be necessary, but keep it on hand for now.
* @code
* $this->context = stream_context_get_default();
* stream_context_set_option($this->context, 'bf', '<option>', '<value>');
* @endcode
*
* @todo: Consider performing common actions here (like loading a Brandfolder API client instance?) and storing results statically, a la:
* @code
* $settings = &drupal_static('BrandfolderStreamWrapper_constructed_settings');
* if (!$settings) {
* $settings = [];
* $settings['client'] = brandfolder_api();
* // ...
* }
* @endcode
*/
public function __construct() {
$this->baseUrl = 'https://cdn.bfldr.com';
// Note: we can't use dependency injection for the DB connection because
// certain low-level operations like is_dir() will not pass any params
// even if arguments are registered in the stream wrapper service
// definition.
$this->connection = \Drupal::database();
$this->logger = \Drupal::logger('brandfolder');
}
/**
* Returns the name of the stream wrapper for use in the UI.
*
* @return string
* The stream wrapper name.
*/
public function getName(): string {
return $this->t('Brandfolder');
}
/**
* {@inheritdoc}
*/
public function getDescription(): string {
return $this->t('Stream wrapper that supports interacting with attachments stored and managed in Brandfolder as though they were something akin to native files');
}
/**
* Base implementation of setUri().
*/
public function setUri($uri) {
$this->uri = $uri;
}
/**
* Base implementation of getUri().
*/
public function getUri(): string {
return $this->uri;
}
/**
* Base implementation of realpath().
*/
public function realpath(): string {
return $this->getUri();
}
/**
* Stream context resource.
*
* @var Resource
*/
public $context;
/**
* A generic resource handle.
*
* @var Resource
*/
public $handle = NULL;
/**
* Stat-related constant.
*
* As part of the inode protection mode returned by stat(), identifies the
* file as a regular file, as opposed to a directory, symbolic link, or other
* type of "file".
*
* @see http://linux.die.net/man/2/stat
*/
const S_IFREG = 0100000;
/**
* Template for stat calls.
*
* All elements must be initialized.
*
* @var array
*/
// @codingStandardsIgnoreStart
protected $_stat = [
// @codingStandardsIgnoreEnd
// Device number.
0 => 0,
'dev' => 0,
// Inode number.
1 => 0,
'ino' => 0,
// Inode protection mode. file_unmanaged_delete() requires is_file() to
// return TRUE.
2 => self::S_IFREG,
// S_IFREG indicates that the item is a regular file, not a directory.
'mode' => self::S_IFREG,
// Number of links.
3 => 0,
'nlink' => 0,
// Userid of owner.
4 => 0,
'uid' => 0,
// Groupid of owner.
5 => 0,
'gid' => 0,
// Device type, if inode device *.
6 => -1,
'rdev' => -1,
// Size in bytes.
7 => 0,
'size' => 0,
// Time of last access (Unix timestamp).
8 => 0,
'atime' => 0,
// Time of last modification (Unix timestamp).
9 => 0,
'mtime' => 0,
// Time of last inode change (Unix timestamp).
10 => 0,
'ctime' => 0,
// Blocksize of filesystem IO.
11 => -1,
'blksize' => -1,
// Number of blocks allocated.
12 => -1,
'blocks' => -1,
];
/**
* Support for stat().
*
* @param string $url
* @param int $flags
*
* @return bool|array
*/
// @codingStandardsIgnoreStart
public function url_stat($url, $flags): array {
// @codingStandardsIgnoreEnd
// Load file size from DB before proceeding.
$file_data_loaded = $this->loadFileData($url);
if (!$file_data_loaded) {
$this->logger->error('Could not load file data for :url', [':url' => $url]);
}
return $this->stream_stat();
}
/**
* Determine the mime type for a given URI.
*/
public static function getMimeType($uri, $mapping = NULL): string {
$mimetype_handler = new BrandfolderMimeTypeHandler(\Drupal::Service('module_handler'), \Drupal::database());
return $mimetype_handler->guessMimeType($uri);
}
/**
* Support for fopen(), file_get_contents(), file_put_contents() etc.
*
* @param string $url
* A string containing the path to the file to open.
* @param string $mode
* The file mode ("r", "wb" etc.).
* @param int $options
* A bit mask of STREAM_USE_PATH and STREAM_REPORT_ERRORS.
* @param string &$opened_url
* A string containing the path actually opened.
*
* @return bool
* TRUE if file was opened successfully.
*/
// @codingStandardsIgnoreStart
public function stream_open($url, $mode, $options, &$opened_url): bool {
// @codingStandardsIgnoreEnd
// We only handle Read-Only mode by default.
if ($mode != 'r' && $mode != 'rb') {
return FALSE;
}
if ($options & STREAM_USE_PATH) {
$opened_url = $url;
}
return TRUE;
}
/**
* Get the absolute URL for a Brandfolder file.
*
* @return string
* A URL string.
*/
public function getExternalUrl(): string {
$config = \Drupal::configFactory()->get('brandfolder.settings');
// The current approach is to store most of the URL information right in the
// URI, so we do not need to perform any additional lookups against the
// BF API or Drupal DB. Thus, a typical URI looks something like
// "bf://SH123456/at/abc123-echvmo-7qf0za/my_image.jpg."
// This is not quite as slick or readable as
// "bf://abc123-echvmo-7qf0za/my_image.jpg"
// or "bf://abc123-echvmo-7qf0za," but it should be more
// performant and allow for straightforward management of (a) attachments
// from multiple Brandfolders if we choose to support that in the future.
// The effective limit on BF attachment filenames (including extension) is
// therefore 217 characters.
$url_options = [
'absolute' => TRUE,
];
$scheme_prefix = 'bf://';
$file_uri = $this->getUri();
$uri_sans_scheme = substr($file_uri, strlen($scheme_prefix));
$query_params = [];
$image_style = NULL;
$mimetype = $this->getMimeType($file_uri);
// Handle image styles.
$image_style_unsupported_mimetypes = [
'image/svg+xml',
];
if (preg_match("/^styles\/([^\/]+)\/bf\/(.*)$/", $uri_sans_scheme, $matches)) {
$image_style_id = $matches[1];
// Remove the style portion of the URI.
$uri_sans_scheme = $matches[2];
// Check for a problematic "extra extension" that may have been slapped
// onto the path (resulting in something like
// "bf://abc123-echvmo-7qf0za/.../my_image.jpg.webp"). Specifically, undo
// the work of \Drupal\image\Entity\ImageStyle::addExtension, which
// appends an extension if its image effects indicate that they would
// alter the original extension (this is true of, e.g., image_convert
// effects). We don't want this because (a) it can interfere with other
// image effects performing lookups (e.g. Focal Point module checking to
// see if it has any crops defined for a given URI),
// (b) having multiple extensions in the URL can cause Brandfolder to
// return a 404, and (c) we don't use the extension portion of the URL
// to request a specific image format (we use the "format" and/or "auto"
// params).
// Brandfolder doesn't allow upload of files with multiple extensions in
// their name, so we can assume that any extras are Drupal-added.
$uri_sans_scheme = preg_replace('/(\.[^.]+)(\.[^.]+)$/', '$1', $uri_sans_scheme);
// Append style name as query param for clarity.
$query_params['drupal-image-style'] = $image_style_id;
if (!in_array($mimetype, $image_style_unsupported_mimetypes)) {
if ($image_style = ImageStyle::load($image_style_id)) {
// Apply all effects from the given image style. Our image toolkit
// will handle compatible effects and add corresponding Smart CDN URL
// transformation params to the image object.
// @todo: Test more scenarios with stacked effects.
$file_uri = "{$scheme_prefix}{$uri_sans_scheme}";
// Note: we will always use the BF image toolkit for BF images,
// without making BF the default sitewide toolkit.
// @see \Drupal\brandfolder\Image\BrandfolderImageFactory.
$image = \Drupal::service('image.factory')->get($file_uri);
if ($image->isValid()) {
foreach ($image_style->getEffects() as $effect) {
// Skip image conversion effects if the Brandfolder module has
// been configured to always use "format=auto" despite
// image-style-specific settings.
if ($config->get('io_format_auto') && $config->get('io_format_auto_force') && $effect->getPluginId() == 'image_convert') {
continue;
}
if (!$effect->applyEffect($image)) {
$this->logger->error('Could not apply the image effect :effect_name to the Brandfolder image :uri.', [
':effect_name' => $effect->label(),
':uri' => $file_uri,
]);
}
}
$bf_params = $image->getToolkit()->getCdnUrlParams();
if (!empty($bf_params)) {
$query_params = array_merge($query_params, $bf_params);
}
}
}
}
}
// Image optimization.
// (We almost always want to do this, but it's counterproductive for SVGs).
if ($mimetype != 'image/svg+xml') {
// Apply the "format=auto" param if that option has been configured. That
// causes the CDN to calculate the best image format to deliver to each
// client/browser based on a variety of factors.
if ($config->get('io_format_auto')) {
// If a format param is already set (e.g. from an image style conversion
// effect), then we might not want to override it. Whether we do or not
// is dictated by the 'io_format_auto_force' setting.
if ($config->get('io_format_auto_force')) {
$query_params['format'] = 'auto';
unset($query_params['auto']);
}
elseif (!isset($query_params['format']) && !isset($query_params['auto'])) {
$query_params['format'] = 'auto';
}
}
elseif ($config->get('io_auto_webp')) {
$query_params['auto'] = 'webp';
}
if (!empty($config->get('io_quality'))) {
$query_params['quality'] = $config->get('io_quality');
}
}
$url = "{$this->baseUrl}/{$uri_sans_scheme}";
// Remove any query params from the original URL and add them to the query
// params array.
$url_components = parse_url($url);
if (!empty($url_components['query'])) {
$original_query = $url_components['query'];
$url = str_replace("?$original_query", '', $url);
$original_query_pairs = explode('&', $original_query);
foreach ($original_query_pairs as $pair) {
$key_value = explode('=', $pair);
if (count($key_value) == 2) {
[$key, $value] = $key_value;
$query_params[$key] = $value;
}
}
}
$url_options['query'] = $query_params;
// Allow other modules to alter the URL.
$context = [
'uri' => $file_uri,
'image_style' => $image_style,
];
\Drupal::moduleHandler()->alter('brandfolder_file_url', $url, $url_options, $context);
return Url::fromUri($url, $url_options)->toString();
}
/**
* Get file data for a given URI.
*
* @param $uri
*
* @return bool
*/
protected function loadFileData($uri): bool {
$success = FALSE;
// For more robust lookups, extract the Brandfolder attachment ID
// from the URI and query against the corresponding column rather than the
// URI. URI is a bit more volatile since BF filenames can change. We also
// want to return a positive match when the URI is an image style derivative
// of a bf:// file.
// This will need to be updated if we start supporting asset URIs or
// supporting multiple Brandfolders in a single Drupal site.
$uri_parts = brandfolder_parse_uri($uri);
if ($uri_parts && isset($uri_parts['type']) && $uri_parts['type'] === 'attachment' && !empty($uri_parts['id'])) {
$attachment_id = $uri_parts['id'];
// @todo: static/cache; data other than file size; etc.
$query = $this->connection->select('brandfolder_file', 'bf')
->fields('bf', ['filesize'])
->condition('bf_attachment_id', $attachment_id);
if ($query->countQuery()->execute()->fetchField()) {
$result = $query->execute();
$row = $result->fetch();
if (!empty($row->filesize)) {
// Set the appropriate items in the _stat array, which is used to
// deliver data in response to file-system-esque requests.
$this->_stat[7] = $this->_stat['size'] = $row->filesize;
}
$success = TRUE;
}
}
return $success;
}
/**
* DrupalStreamWrapperInterface implementations, etc.
*/
// @codingStandardsIgnoreStart
/**
* Undocumented PHP stream wrapper method.
*/
public function stream_lock($operation): bool {
return TRUE;
}
/**
* Support for fread(), file_get_contents() etc.
*
* @param int $count
* Maximum number of bytes to be read.
*
* @return bool
* The string that was read, or FALSE in case of an error.
*/
public function stream_read($count): bool {
return FALSE;
}
/**
* Support for fwrite(), file_put_contents() etc.
*
* Since this is a read only stream wrapper this always returns false.
*
* @param string $data
* The string to be written.
*
* @return bool
* Returns FALSE.
*/
public function stream_write($data): bool {
return FALSE;
}
/**
* Support for feof().
*
* @return bool
* TRUE if end-of-file has been reached.
*/
public function stream_eof(): bool {
return FALSE;
}
/**
* Support for fseek().
*
* @param int $offset
* The byte offset to got to.
* @param string $whence
* SEEK_SET, SEEK_CUR, or SEEK_END.
*
* @return bool
* TRUE on success
*/
public function stream_seek($offset, $whence = SEEK_SET): bool {
return FALSE;
}
/**
* Support for fflush().
*
* @return bool
* TRUE if data was successfully stored (or there was no data to store).
*/
public function stream_flush(): bool {
return TRUE;
}
/**
* Support for ftell().
*
* @return bool
* The current offset in bytes from the beginning of file.
*/
public function stream_tell(): bool {
return FALSE;
}
/**
* 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.
*/
public function stream_stat(): array {
return $this->_stat;
}
/**
* Support for fclose().
*
* @return bool
* TRUE if stream was successfully closed.
*/
public function stream_close(): bool {
return TRUE;
}
/**
* Support for opendir().
*
* @param string $url
* A string containing the url to the directory to open.
* @param int $options
* Whether or not to enforce safe_mode (0x04).
*
* @return bool
* TRUE on success.
*/
public function dir_opendir($url, $options): bool {
return FALSE;
}
/**
* Support for readdir().
*
* @return bool
* The next filename, or FALSE if there are no more files in the directory.
*/
public function dir_readdir(): bool {
return FALSE;
}
/**
* Support for rewinddir().
*
* @return bool
* TRUE on success.
*/
public function dir_rewinddir(): bool {
return FALSE;
}
/**
* Support for closedir().
*
* @return bool
* TRUE on success.
*/
public function dir_closedir(): bool {
return FALSE;
}
/**
* 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(): string {
return '';
}
/**
* Implements DrupalStreamWrapperInterface::unlink().
*/
public function unlink($uri): bool {
// Although the remote file itself can't be deleted, return TRUE so that
// file_delete() can remove the file record from the Drupal database.
return TRUE;
}
/**
* Implements DrupalStreamWrapperInterface::rename().
*/
public function rename($from_uri, $to_uri): bool {
return FALSE;
}
/**
* Implements DrupalStreamWrapperInterface::mkdir().
*/
public function mkdir($uri, $mode, $options): bool {
return TRUE;
}
/**
* Implements DrupalStreamWrapperInterface::rmdir().
*/
public function rmdir($uri, $options): bool {
return FALSE;
}
/**
* Implements DrupalStreamWrapperInterface::chmod().
*/
public function chmod($mode): bool {
return FALSE;
}
/**
* Implements DrupalStreamWrapperInterface::dirname().
*/
public function dirname($uri = NULL): bool {
return FALSE;
}
/**
* Implements DrupalStreamWrapperInterface::stream_truncate().
*/
public function stream_truncate($new_size): bool {
return FALSE;
}
/**
* Implements DrupalStreamWrapperInterface::stream_set_option().
*/
public function stream_set_option($option, $arg1, $arg2): bool {
return FALSE;
}
/**
* Implements DrupalStreamWrapperInterface::stream_cast().
*/
public function stream_cast($cast_as): bool {
return FALSE;
}
/**
* Implements DrupalStreamWrapperInterface::stream_metadata().
*
* @see http://www.php.net/manual/streamwrapper.stream-metadata.php
*/
public function stream_metadata($path, $option, $value): bool {
// Allow chown, etc even though we don't let these operations have any
// effect on the underlying Brandfolder attachments.
return TRUE;
}
// @codingStandardsIgnoreEnd
}
