cloudflare_stream-8.x-1.0/src/StreamWrapper/CloudflareStreamWrapper.php
src/StreamWrapper/CloudflareStreamWrapper.php
<?php
declare(strict_types=1);
namespace Drupal\cloudflare_stream\StreamWrapper;
use Drupal\cloudflare_stream\Service\CloudflareStream;
use Drupal\cloudflare_stream\Service\CloudflareStreamApi;
use Drupal\cloudflare_stream\Tus\UploadClient;
use Drupal\Component\Datetime\DateTimePlus;
use Drupal\Core\StreamWrapper\LocalStream;
use Drupal\Core\StreamWrapper\StreamWrapperInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\TempStore\PrivateTempStore;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
/**
* Uses LocalStream for write operations and Cloudflare API for read.
*
* New file upload flow:
* - File is selected in the widget
* - ::url_stat() [prepareDirectory]
* - ::url_stat() [file_exists]
* - ::stream_open('wb')
* - ::stream_write()
* - ::stream_flush()
* - ::stream_close() [File is uploaded]
* - ::url_stat() [File meta-data]
* - ::stream_metadata() [chmod]
* - ::url_stat() [file size]
*/
final class CloudflareStreamWrapper extends LocalStream {
use StringTranslationTrait;
/**
* Instance URI (stream).
*
* This stream is read as "cfstream://{video_id}".
* Demo: cfstream://1abc234de567f8901234g5678hi90j12
* It is written to as: cfstream://{source_file}
* Demo: cfstream://vid.mp4
*
* The StreamWrapper interface does not provide a means to alter the uri and
* the video ID will not be available until after the file is uploaded
* to Cloudflare. The video id is temporarily stored in ::stream_close()
* and the uri is reset in CloudflareVideoItem::preSave.
*
* @var string
*
* @see \Drupal\cloudflare_stream\Plugin\Field\FieldType\CloudflareVideoItem::preSave()
* @see self::stream_close()
*/
protected $uri;
/**
* Retrieved video information.
*
* @var mixed[]
*/
protected array $videos;
/**
* The current index of the read position.
*
* @see self::dir_readdir()
* @see self::dir_rewinddir()
*/
protected int $videoPointer;
/**
* Total number of videos stored in Cloudflare.
*/
protected int $videosAvailable;
/**
* Date of the oldest video info retrieved.
*/
protected DateTimePlus $loadedThrough;
/**
* The mode in which the stream was opened.
*/
protected string $mode;
/**
* Account data.
*/
protected CloudflareStream $cloudflareStream;
/**
* API service.
*/
protected CloudflareStreamApi $cfStreamApi;
/**
* Key-value store to hold video ID for our field type.
*/
protected PrivateTempStore $tempStore;
public function __construct(
?CloudflareStream $cloudflareStream = NULL,
?CloudflareStreamApi $cloudflareStreamApi = NULL,
?PrivateTempStoreFactory $tempStoreFactory = NULL,
protected ?UploadClient $client = NULL,
) {
/* Dependency injection will not work here, since PHP doesn't give us a
* chance to perform the injection. PHP creates the stream wrapper objects
* automatically when certain file functions are called. Instead we'll use
* the \Drupal service locator. However, we've also included nullable
* parameters on the constructor so that this wrapper can be tested.
*/
// phpcs:ignore DrupalPractice.Objects.GlobalDrupal.GlobalDrupal
$this->cloudflareStream = $cloudflareStream ?? \Drupal::service('cloudflare_stream');
$this->cfStreamApi = $cloudflareStreamApi ?? \Drupal::service('cloudflare_stream.api');
$factory = $tempStoreFactory ?? \Drupal::service('tempstore.private');
$this->tempStore = $factory->get('cloudflare_stream');
}
/* ==== Helper methods ==== */
/**
* Checks a file id in Cloudflare.
*
* @return bool
* True if the id returns video details.
*/
protected function isRemoteFileId(?string $path = NULL): bool {
$path = $path ?? $this->uri;
$details = [];
$id = $this->getIdFromUri($path);
if (!empty($id)) {
$details = $this->cfStreamApi->getDetails($id);
}
return $details !== [];
}
/**
* Parse the URI and get the ID (first slug) if present.
*
* @param string|null $path
* Option path. Uses $this->uri if omitted.
*
* @return string
* ID or empty string if missing.
*/
protected function getIdFromUri(?string $path = NULL): string {
$path = $path ?? $this->uri;
$id = parse_url($path, PHP_URL_HOST);
if ($id === FALSE) {
// There was not host property.
$id = '';
}
return $id;
}
/**
* A testing client may already be injected, normally we create one.
*
* @param string $uploadUrl
* The upload URL obtained from Cloudflare.
* @param string $filePath
* The file path to the source file.
*/
protected function configureUploadClient(string $uploadUrl, string $filePath) {
if (is_null($this->client)) {
$logger = \Drupal::logger('cloudflare_stream');
$this->client = new UploadClient($uploadUrl, $filePath, $this->cloudflareStream->getApiToken(), $logger);
}
}
/* ==== \Drupal\Core\StreamWrapper\StreamWrapperInterface methods ==== */
/**
* {@inheritdoc}
*/
public static function getType() {
return StreamWrapperInterface::WRITE_VISIBLE;
}
/**
* {@inheritdoc}
*/
public function getDirectoryPath() {
return \Drupal::service('file_system')->getTempDirectory();
}
/**
* {@inheritdoc}
*/
public function getName() {
return $this->t('Cloudflare Stream');
}
/**
* {@inheritdoc}
*/
public function getDescription() {
return $this->t('Videos served by the Cloudflare Stream service.');
}
/**
* {@inheritdoc}
*/
public function getExternalUrl() {
$id = $this->getIdFromUri();
$subdomain = $this->cloudflareStream->getCustomerSubdomain();
if (empty($subdomain) || empty($id)) {
return '';
}
return "https://$subdomain.cloudflarestream.com/$id/watch";
}
/**
* {@inheritdoc}
*/
public function dirname($uri = NULL) {
$path = $this->getLocalPath($uri ?? $this->uri);
if ($path === FALSE) {
return FALSE;
}
return parent::dirname($uri);
}
/* The PhpStreamWrapperInterface calls for method names that do not conform
* to Drupal standards.
*/
// phpcs:disable Drupal.NamingConventions.ValidFunctionName.ScopeNotCamelCaps
/**
* {@inheritdoc}
*/
public function dir_closedir() {
$this->videoPointer = 0;
$this->videos = [];
$this->videosAvailable = 0;
$this->loadedThrough = new DateTimePlus('@0');
return TRUE;
}
/**
* {@inheritdoc}
*/
public function dir_opendir($path = '', $options = NULL) {
// Reset and initialize.
$videos = $this->cfStreamApi->listVideos(asc: TRUE);
$successful = $videos['success'] ?? FALSE;
if ($successful) {
$this->videoPointer = 0;
$this->videos = $videos['result'];
$this->videosAvailable = $videos['total'];
$last = end($this->videos);
$this->loadedThrough = new DateTimePlus($last['created']);
return TRUE;
}
return FALSE;
}
/**
* {@inheritdoc}
*/
public function dir_readdir() {
if ($this->videoPointer < count($this->videos)) {
$next = $this->videos[$this->videoPointer];
$this->videoPointer++;
return $next;
}
elseif ($this->videoPointer < $this->videosAvailable) {
$after = $this->loadedThrough->add(\DateInterval::createFromDateString('1 second'));
$videos = $this->cfStreamApi->listVideos(
after: $after->format(DATE_ATOM),
asc: TRUE,
);
$successful = $videos['success'] ?? FALSE;
if ($successful && is_array($videos['result'])) {
array_push($this->videos, $videos['result']);
$this->videosAvailable = $videos['total'];
$last = end($this->videos);
$this->loadedThrough = new DateTimePlus($last['created']);
$next = $this->videos[$this->videoPointer];
$this->videoPointer++;
return $next;
}
}
return FALSE;
}
/**
* {@inheritdoc}
*/
public function dir_rewinddir() {
$this->videoPointer = 0;
return TRUE;
}
/**
* {@inheritdoc}
*/
public function mkdir($path, $mode, $options) {
trigger_error('mkdir() not supported for CloudflareStreamWrapper', E_USER_WARNING);
return FALSE;
}
/**
* {@inheritdoc}
*/
public function rename($path_from, $path_to) {
trigger_error('rename() not supported for CloudflareStreamWrapper', E_USER_WARNING);
return FALSE;
}
/**
* {@inheritdoc}
*/
public function rmdir($path, $options) {
trigger_error('rmdir() not supported for CloudflareStreamWrapper', E_USER_WARNING);
return FALSE;
}
/**
* {@inheritdoc}
*/
public function stream_cast($cast_as) {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function stream_open($path, $mode, $options, &$opened_path) {
$this->uri = $path;
// We are simply passing through data and ignore suffix characters.
$this->mode = rtrim($mode, 'bt');
return match ($this->mode) {
'r' => $this->isRemoteFileId() || parent::stream_open($path, $mode, $options, $opened_path),
'w' => parent::stream_open($path, $mode, $options, $opened_path),
default => FALSE,
};
}
/**
* {@inheritdoc}
*/
public function stream_close() {
$closed = parent::stream_close();
if ($this->mode !== 'w') {
return TRUE;
}
$path = $this->getLocalPath();
// Get size.
$info = @stat($path);
$size = $info['size'] ?? 1;
$name = base64_encode($this->getIdFromUri());
$parameters = $this->cfStreamApi->initiateTusUpload(
length: $size,
metaData: "name $name",
);
// Get an upload url and id.
if ($closed && $parameters->ready) {
$videoId = $parameters->videoId;
// Configure the upload client.
$this->configureUploadClient($parameters->destinationUrl, $path);
$this->client->upload();
// Store the video ID for use by the field widget.
$this->tempStore->set($this->uri, $videoId);
// Remove the uploaded file.
$this->unlink($this->uri);
}
// Clean up.
$this->uri = '';
$this->mode = '';
return $closed;
}
/**
* {@inheritdoc}
*/
public function stream_metadata($path, $option, $value) {
if ($this->isRemoteFileId($path)) {
// $path is not a local file name.
return FALSE;
}
return parent::stream_metadata($path, $option, $value);
}
/**
* {@inheritdoc}
*/
public function stream_read($count) {
return $this->cfStreamApi->getDetails($this->getIdFromUri());
}
/**
* {@inheritdoc}
*/
public function stream_set_option($option, $arg1, $arg2) {
trigger_error('stream_set_option() not supported for CloudflareStreamWrapper', E_USER_WARNING);
return FALSE;
}
/**
* {@inheritdoc}
*/
public function stream_stat() {
$id = $this->getIdFromUri();
if (empty($id) && !is_null($this->handle)) {
// Asking for root - pass to parent.
return fstat($this->handle);
}
else {
// Full path given, get data from Cloudflare.
// Treat as file that is readable if data is returned.
// Otherwise return parent.
$mode = 0100000 | 0444;
$info = $this->cfStreamApi->getDetails($id);
if (empty($info)) {
return is_null($this->handle) ? FALSE : fstat($this->handle);
}
$size = $info['size'];
}
$stat = array_fill(0, 13, 0);
$stat['dev'] = 0;
$stat['ino'] = 0;
$stat[2] = $stat['mode'] = $mode;
$stat['nlink'] = 0;
$stat['uid'] = 0;
$stat['gid'] = 0;
$stat['rdev'] = 0;
$stat[7] = $stat['size'] = $size;
$stat['atime'] = 0;
$stat['mtime'] = 0;
$stat['ctime'] = 0;
$stat['blksize'] = 0;
$stat['blocks'] = 0;
return $stat;
}
/**
* {@inheritdoc}
*/
public function unlink($uri) {
$target = $this->getLocalPath($uri);
if ($target === FALSE) {
// $path is no longer local.
return $this->cfStreamApi->deleteVideo($this->getIdFromUri($uri));
}
return parent::unlink($uri);
}
/**
* {@inheritdoc}
*/
public function url_stat($uri, $flags) {
$this->uri = $uri;
$path = $this->getLocalPath();
$isLocal = $path !== FALSE;
if ($flags & STREAM_URL_STAT_QUIET) {
return $isLocal ? @stat($path) : @$this->stream_stat();
}
else {
return $isLocal ? stat($path) : $this->stream_stat();
}
}
}
