azure_blob_fs-8.x-1.0-beta3/src/StreamWrapper/AzureBlobFsStream.php
src/StreamWrapper/AzureBlobFsStream.php
<?php
namespace Drupal\azure_blob_fs\StreamWrapper;
use ArrayObject;
use Drupal\azure_blob_fs\Constants\Services as ModuleServices;
use Drupal\Core\StreamWrapper\PublicStream;
use Drupal\Core\StreamWrapper\StreamWrapperInterface;
use Drupal\Core\StreamWrapper\StreamWrapperManager;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use MicrosoftAzure\Storage\Blob\BlobRestProxy;
use MicrosoftAzure\Storage\Blob\Models\Blob;
use MicrosoftAzure\Storage\Blob\Models\BlobPrefix;
use MicrosoftAzure\Storage\Blob\Models\Block;
use MicrosoftAzure\Storage\Blob\Models\BlockList;
use MicrosoftAzure\Storage\Blob\Models\GetBlobResult;
use MicrosoftAzure\Storage\Blob\Models\ListBlobsOptions;
use MicrosoftAzure\Storage\Common\Exceptions\ServiceException;
/**
* Defines a Drupal Azure Blob stream wrapper class.
*
* Provides support for storing files on a Microsoft Azure Blob Storage for the
* Drupal file interface.
*
* Now I'll be honest, there's still a lot of things to fix and understand in
* this file, but this is THE file to modify when it comes to the core functions
* of the module.
*/
abstract class AzureBlobFsStream implements StreamWrapperInterface {
// String Translation Trait.
use StringTranslationTrait;
/**
* The Azure Blob Storage client.
*
* @var \MicrosoftAzure\Storage\Blob\BlobRestProxy
*/
protected static $client;
/**
* The Azure Blob Storage Account Name.
*
* @var string
*/
protected static $accountName;
/**
* The Azure Blob Storage Account Key.
*
* @var string
*/
protected static $accountKey;
/**
* The Azure Blob Storage URL.
*
* @var string
*/
protected static $blobStorageUrl;
/**
* The Azure Blob Storage SAS Token.
*
* @var string
*/
protected static $blobStorageSASToken;
/**
* The Azure Blob Storage blob container being used.
*
* @var string
*/
protected static $container;
/**
* Store the local stream base URL.
*
* @var string
*/
protected static $localStreamBaseUrl;
/**
* The Azure Blob FS service injected through DI.
*
* @var \Drupal\azure_blob_fs\Service\AzureBlobFsServiceInterface
*/
protected $azureBlobFsService;
/**
* What the fuck is this?
*
* @var \ArrayIterator
*/
protected $iterator;
/**
* Mode in which the stream was opened.
*
* @var string
*/
protected $mode;
/**
* Instance uri referenced as "<scheme>://key".
*
* @var string
*/
protected $uri;
/**
* A generic resource handle.
*
* @var resource
*/
public $handle;
/**
* Constructs a new AzureBlobFsStream object.
*/
public function __construct() {
$this->azureBlobFsService = \Drupal::service(ModuleServices::AZURE_BLOB_FS);
// Set static settings from the site.
static::$accountName = $this->azureBlobFsService->getConfiguredAccountName();
static::$accountKey = $this->azureBlobFsService->getConfiguredAccountKey();
static::$blobStorageUrl = $this->azureBlobFsService->getConfiguredUrl();
static::$blobStorageSASToken = $this->getConfiguredSasToken();
static::$container = $this->getConfiguredContainerName();
static::$localStreamBaseUrl = PublicStream::baseUrl();
}
/**
* Return the scheme used in this stream.
*
* For this module, it's either 'public' or 'private'.
*
* @return string
* The scheme being used in this stream.
*/
abstract protected function scheme(): string;
/**
* Return the configured sas token depending on the Stream.
*
* There may be a sas token per stream.
*
* @return string
* The configured sas token.
*/
protected function getConfiguredSasToken(): string {
return $this->azureBlobFsService->getConfiguredSasToken($this->scheme());
}
/**
* Return the configured container name depending on the Stream.
*
* There is a container per stream.
*
* @return string
* The configured container name.
*/
protected function getConfiguredContainerName(): string {
return $this->azureBlobFsService->getConfiguredContainerName($this->scheme());
}
/**
* {@inheritdoc}
*/
public static function getType(): int {
return StreamWrapperInterface::NORMAL;
}
/**
* {@inheritdoc}
*/
public function getName(): string {
return $this->t('Azure Blob File System Stream');
}
/**
* {@inheritdoc}
*/
public function getDescription(): string {
return $this->t('Provides an Azure Blob File Storage service-based remote file system to Drupal');
}
/**
* 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.
*
* {@inheritdoc}
*/
public function getDirectoryPath(): string {
return '';
}
/**
* {@inheritdoc}
*/
public function setUri($uri): void {
$this->uri = $uri;
}
/**
* {@inheritdoc}
*/
public function getUri(): string {
return $this->uri;
}
/**
* Get external URL of a resource.
*
* We also handle image style derivatives in here.
*
* It may not be ideal to do this here for the sake of performance. In the
* future, we need to find a way to refactor our code and handle image styles
* better.
*
* The Amazon S3 File System module handles image styles more like how the
* core does it, but many problems arose when I first tried to implement that.
*
* This is the solution for now.
*
* {@inheritdoc}
*/
public function getExternalUrl(): string {
// Get the target destination without the scheme.
$target = StreamWrapperManager::getTarget($this->uri);
// If this is an image style request, we want to fallback to our local fs.
if (strpos($target, 'styles/') === 0) {
return static::$localStreamBaseUrl . '/' . $target;
}
// If there is no target we won't return path to the bucket,
// instead we'll return empty string.
if (empty($target)) {
return '';
}
// Get the remote URI.
$remoteUri = $this->getRemoteFilePath($target) ?? '';
// If the remote URI is empty, we can stop here.
return $remoteUri;
}
/**
* This wrapper does not support realpath().
*
* {@inheritdoc}
*/
public function realpath(): bool {
return FALSE;
}
/**
* Emulates the closing of a directory handle.
*
* Sets our stream's iterator to NULL.
*
* {@inheritdoc}
*/
public function dir_closedir(): bool {
$this->iterator = NULL;
return TRUE;
}
/**
* Emulates the opening of a directory handle.
*
* Sets up our iterator with the given uri if it's a directory.
*
* {@inheritdoc}
*/
public function dir_opendir($uri, $options): bool {
// Get the path.
$path = StreamWrapperManager::getTarget($uri);
// Get remote path info.
$remotePathInfo = $this->remotePathInfo($path);
// If this path is a file, we return TRUE.
if (isset($remotePathInfo[1]) && $remotePathInfo[1] === 'file') {
// Path is a file path. Returns TRUE without creating the iterator.
return FALSE;
}
// Get the blobs from the virtual directory.
$blobs = $this->getVirtualFolder($path, ['exclude_dirs' => TRUE]);
if ($blobs === NULL) {
return FALSE;
}
// Build our iterator.
$blobsArrayObj = new ArrayObject($blobs);
$this->iterator = $blobsArrayObj->getIterator();
return TRUE;
}
/**
* {@inheritdoc}
*/
public function dir_readdir() {
// If our iterator is empty, there is nothing to read.
// This also means there is likely no directory.
if ($this->iterator === NULL) {
return FALSE;
}
// Get the current iterator item.
$current = $this->iterator->current();
// If there is no current item, we are at the end of our iterator.
if ($current === NULL) {
return FALSE;
}
// Proceed to the next item.
$this->iterator->next();
// Return the current item's name, or FALSE otherwise.
return basename($current->getName()) ?? FALSE;
}
/**
* {@inheritdoc}
*/
public function dir_rewinddir(): ?bool {
// If our iterator is empty, there is nothing to rewind.
// This also means there is likely no directory.
if ($this->iterator === NULL) {
return FALSE;
}
// Try to rewind our iterator.
try {
$this->iterator->rewind();
return TRUE;
}
catch (ServiceException $e) {
watchdog_exception('Azure Blob Stream Service Exception (dir_rewinddir)', $e);
return FALSE;
}
catch (\Exception $e) {
watchdog_exception('Azure Blob Stream Exception (dir_rewinddir)', $e);
return FALSE;
}
}
/**
* Azure Blob Storages don't support physical directories so always return
* TRUE.
*
* There is the concept of Virtual Folders, but they can't be created
* programmatically. So we'll just ignore that.
*
* {@inheritdoc}
*/
public function mkdir($path, $mode, $options): bool {
return TRUE;
}
/**
* Called in response to rename() to rename a file or directory. Currently
* only supports renaming objects.
*
* In Azure Blob Storage, we have to copy the existing blob to a new one with
* the new name, then delete the original.
*
* Now...Renaming folders might be very heavy. We'll see about adding that in
* the future.
*
* {@inheritdoc}
*/
public function rename($path_from, $path_to): bool {
try {
$this->getClient()->copyBlob(static::$container, $path_to, static::$container, $path_from);
$this->getClient()->deleteBlob(static::$container, $path_from);
clearstatcache(TRUE, $path_from);
clearstatcache(TRUE, $path_to);
}
catch (ServiceException $e) {
watchdog_exception('Azure Blob Stream Service Exception (rename)', $e);
return FALSE;
}
catch (\Exception $e) {
watchdog_exception('Azure Blob Stream Exception (rename)', $e);
return FALSE;
}
return TRUE;
}
/**
* {@inheritdoc}
*/
public function rmdir($uri, $options): bool {
return $this->deleteRemotePath($uri);
}
/**
* {@inheritdoc}
*/
public function stream_cast($cast_as) {
return $this->handle ?: FALSE;
}
/**
* {@inheritdoc}
*/
public function stream_close(): bool {
return fclose($this->handle);
}
/**
* {@inheritdoc}
*/
public function stream_eof(): bool {
return feof($this->handle);
}
/**
* The actual upload to Azure is handled in this function.
*
* @see https://www.php.net/manual/en/function.fflush.php
*
* {@inheritdoc}
*/
public function stream_flush(): ?bool {
if ($this->mode === 'r') {
return FALSE;
}
try {
// Seek to 0.
fseek($this->handle, 0);
$blockId = substr(base64_encode(basename($this->uri)), 0, 64);
$blocks = [new Block($blockId, 'Uncommitted')];
$blockList = BlockList::create($blocks);
$blobName = StreamWrapperManager::getTarget($this->uri);
$this->getClient()->createBlobBlock(static::$container, $blobName, $blockId, $this->handle);
$this->getClient()->commitBlobBlocks(static::$container, $blobName, $blockList);
$this->iterator = FALSE;
return TRUE;
}
catch (ServiceException $e) {
watchdog_exception('Azure Blob Stream Service Exception (stream_flush)', $e);
\Drupal::messenger()->addWarning('The file "'.$this->uri.'" could not be created in the Azure Blob Storage. Please review the recent log messages for additional details.');
return FALSE;
}
catch (\Exception $e) {
watchdog_exception('Azure Blob Stream Exception (stream_flush)', $e);
\Drupal::messenger()->addWarning('The file "'.$this->uri.'" could not be created in the Azure Blob Storage. Please review the recent log messages for additional details.');
return FALSE;
}
}
/**
* {@inheritdoc}
*/
public function stream_lock($operation): bool {
if (\in_array($operation, [LOCK_SH, LOCK_EX, LOCK_UN, LOCK_NB], TRUE)) {
return flock($this->handle, $operation);
}
return TRUE;
}
/**
* Sets metadata on the stream.
*
* Manual recommends return FALSE for not implemented options, but Drupal
* requires TRUE in some cases like chmod for avoid watchdog errors.
*
* @see http://php.net/manual/en/streamwrapper.stream-metadata.php
* @see \Drupal\Core\File\FileSystem::chmod()
*
* Returns FALSE if the option is not included in bypassed_options array
* otherwise, TRUE.
*
* @see http://php.net/manual/streamwrapper.stream-metadata.php
*
* {@inheritdoc}
*/
public function stream_metadata($path, $option, $value): bool {
$bypassed_options = [STREAM_META_ACCESS];
return \in_array($option, $bypassed_options, TRUE);
}
/**
* When opening a file, we want to get it from the Azure Blob.
*
* {@inheritdoc}
*/
public function stream_open($uri, $mode, $options, &$opened_path): bool {
$this->setUri($uri);
$this->mode = rtrim($mode, 'bt+');
switch ($this->mode) {
case 'r': return $this->openReadStream();
case 'a': return $this->openAppendStream();
default: return $this->openWriteStream();
}
}
/**
* Open a file reading stream.
*
* This is inspired by the Amazon S3 module. Subject to change.
*
* @return bool
* Returns TRUE on success, FALSE on failure.
*/
private function openReadStream(): bool {
// Get the target destination without the scheme.
$target = StreamWrapperManager::getTarget($this->uri);
// We try to get a blob.
// Get the blob.
$blob = $this->getBlob($target);
// If the blob is empty, we get outta here.
if ($blob !== NULL) {
// Get the contents and store them in our handle.
$this->handle = $blob->getContentStream();
} else {
return FALSE;
}
return TRUE;
}
/**
* Open a file writing stream.
*
* This is inspired by the Amazon S3 module. Subject to change.
*
* @return bool
* Returns TRUE regardless.
*/
private function openWriteStream(): bool {
$this->handle = fopen('php://temp', 'r+b');
return TRUE;
}
/**
* Open a file appending stream.
*
* This is inspired by the Amazon S3 module. Subject to change.
*
* @return bool|null
* Returns TRUE upon completion.
*/
private function openAppendStream(): ?bool {
// Get the target destination without the scheme.
$target = StreamWrapperManager::getTarget($this->uri);
// Get the blob.
$blob = $this->getBlob($target);
// If the blob is empty, we get outta here.
if ($blob !== NULL) {
// Set the the handle
$this->handle = $blob->getContentStream();
@fseek($this->handle, 0, SEEK_END);
return TRUE;
}
// The object does not exist, so use a simple write stream
return $this->openWriteStream();
}
/**
* {@inheritdoc}
*/
public function stream_read($count): string {
return fread($this->handle, $count);
}
/**
* {@inheritdoc}
*/
public function stream_seek($offset, $whence = SEEK_SET): bool {
return !fseek($this->handle, $offset, $whence);
}
/**
* {@inheritdoc}
*/
public function stream_set_option($option, $arg1, $arg2): bool {
return FALSE;
}
/**
* @noinspection ReturnTypeCanBeDeclaredInspection
*
* {@inheritdoc}
*/
public function stream_stat() {
return fstat($this->handle);
}
/**
* {@inheritdoc}
*/
public function stream_tell(): int {
return ftell($this->handle);
}
/**
* {@inheritdoc}
*/
public function stream_truncate($new_size): bool {
return ftruncate($this->handle, $new_size);
}
/**
* {@inheritdoc}
*/
public function stream_write($data): int {
return fwrite($this->handle, $data);
}
/**
* {@inheritdoc}
*/
public function unlink($uri): bool {
// Delete what is found at the provided URI.
return $this->deleteRemotePath($uri);
}
/**
* @TODO - This needs to be revised...
* @noinspection ReturnTypeCanBeDeclaredInspection
*
* {@inheritdoc}
*/
public function url_stat($uri, $flags) {
// Get target.
$path = StreamWrapperManager::getTarget($uri);
// Attempt to get information on the path.
$pathInfo = $this->remotePathInfo($path);
// Initialize the stat array.
$stat = array_fill_keys([
'dev',
'ino',
'mode',
'nlink',
'uid',
'gid',
'rdev',
'size',
'atime',
'mtime',
'ctime',
'blksize',
'blocks'
], 0);
// If our path information is false, we return here.
if (!$pathInfo[0]) {
return FALSE;
}
// If we have ourselves a file, get file statistics.
if ($pathInfo[1] === 'file') {
$blob = $pathInfo[2];
/* @var \MicrosoftAzure\Storage\Blob\Models\BlobProperties $properties */
$properties = $blob->getProperties();
// All files are considered writable, so OR in 0777.
$stat['mode'] = 0100000 | 0777;
$stat['size'] = $properties->getContentLength();
$stat['mtime'] = date_timestamp_get($properties->getLastModified());
$stat['blksize'] = -1;
$stat['blocks'] = -1;
return $stat;
}
// If we have ourselves a directory, then I have no fucking idea what to do.
if ($pathInfo[1] === 'folder') {
// Do things.
return FALSE;
}
return FALSE;
}
/**
* {@inheritdoc}
*/
public function dirname($uri = NULL): string {
if ($uri === NULL) {
$uri = $this->uri;
}
$fs = \Drupal::service('file_system');
$scheme = StreamWrapperManager::getScheme($uri);
$dirname = $fs->dirname(StreamWrapperManager::getTarget($uri));
// When the dirname() call above is given '$scheme://', it returns '.'.
// But '$scheme://.' is an invalid uri, so we return "$scheme://" instead.
if ($dirname === '.') {
$dirname = '';
}
return "$scheme://$dirname";
}
/**
* Return a client connection to an Azure Blob Storage.
*
* @return \MicrosoftAzure\Storage\Blob\BlobRestProxy
* The generated Azure Blob client.
*/
protected function getClient(): BlobRestProxy {
// Return our static variable if it's set.
if (static::$client !== NULL) {
return static::$client;
}
// If the client hasn't been set up yet, or the config given to this call is
// different from the previous call, (re)build the client.
$connectionString = $this->buildConnectionString(static::$blobStorageUrl, static::$blobStorageSASToken);
// Create the client.
static::$client = BlobRestProxy::createBlobService($connectionString);
return static::$client;
}
/**
* Build an Azure File Storage connection string.
*
* @param string $url
* The Blob Storage URL.
* @param string $sas
* The SAS token.
*
* @return string
* The connection string if it was properly built.
*/
protected function buildConnectionString(string $url, string $sas): string {
return 'BlobEndpoint=' . $url . ';SharedAccessSignature=' . $sas;
}
/**
* Get a remote file from the Azure Blob Storage.
*
* @param string $path
* Path to the remote file.
*
* @return null|resource
* Returns the resource of the file if found, NULL otherwise.
*/
protected function getRemoteFile(string $path) {
// @TODO - Document your shit!
// Attempt to get file from path.
$blob = $this->getBlob($path);
if ($blob === NULL) {
return NULL;
}
return $blob->getContentStream();
}
/**
* Get the real path to a remote file given a relative path.
*
* @param string $path
* Path to the file relative to the Blob Storage.
*
* @return null|string
* The full remote path to the file upon success, NULL otherwise.
*/
protected function getRemoteFilePath(string $path): ?string {
// @TODO - Document your shit!
// Attempt to get file from path.
try {
return $this->getClient()->getBlobUrl(static::$container, $path);
}
catch (ServiceException $e) {
watchdog_exception('Azure Blob Stream Service Exception (getRemoteFilePath)', $e);
return NULL;
}
catch (\Exception $e) {
watchdog_exception('Azure Blob Stream Exception (getRemoteFilePath)', $e);
return NULL;
}
}
/**
* Get Azure Blob from the remote storage.
*
* @param string $path
* The path, or "name" of the blob.
*
* @return \MicrosoftAzure\Storage\Blob\Models\GetBlobResult|null
* Returns a blob if it was found, or NULL otherwise.
*/
protected function getBlob(string $path): ?GetBlobResult {
// Attempt to get file from path.
try {
// If it was found, return it straight up.
return $this->getClient()->getBlob(static::$container, $path);
}
catch (ServiceException $e) {
if ($e->getCode() !== 404) {
watchdog_exception('Azure Blob Stream Service Exception (getBlob)', $e);
}
return NULL;
}
catch (\Exception $e) {
watchdog_exception('Azure Blob Stream Exception (getBlob)', $e);
return NULL;
}
}
/**
* Get Azure Virtual Folder from the remote storage.
*
* Azure doesn't have REAL directories or folders. They're virtual. Only the
* full path of each blob is saved.
*
* @param string $path
* The path to the virtual folder.
* @param array $options
* Custom options to affect the returned output.
*
* @return array|\MicrosoftAzure\Storage\Blob\Models\Blob[]|null
* Return an array of Blobs/BlobPrefixes, or NULL if nothing was found.
*/
protected function getVirtualFolder(string $path, array $options = ['exclude_dirs' => FALSE]) : ?array {
// Set our prefix to the provided path.
// The prefix will allow the following code to search for relevant blobs.
// If the prefix is just a slash, we set the prefix to empty.
// This will make us list all blobs at the root.
$prefix = $path;
if ($path === '/') {
$prefix = '';
}
// Otherwise, if our prefix doesn't end with a slash, append one.
elseif (substr($path, -1) !== '/') {
$prefix = $path . '/';
}
// Set up our list options.
$listOptions = new ListBlobsOptions();
$listOptions->setPrefix($prefix);
$listOptions->setDelimiter('/');
try {
$result = $this->getClient()->listBlobs(static::$container, $listOptions);
$folderData = $result->getBlobs() ?? [];
if (!$options['exclude_dirs']) {
$blobPrefixes = $result->getBlobPrefixes() ?? [];
// Merge in any blob prefixes as well.
$folderData = array_merge($folderData, $blobPrefixes);
}
}
catch (ServiceException $e) {
watchdog_exception('Azure Blob Stream Service Exception (getVirtualFolder)', $e);
$folderData = [];
}
catch (\Exception $e) {
watchdog_exception('Azure Blob Stream Exception (getVirtualFolder)', $e);
$folderData = [];
}
// If there are blobs, then we have ourselves a folder.
if (!empty($folderData)) {
return $folderData;
}
// Otherwise, return an empty array.
return NULL;
}
/**
* Get information on a remote path towards the Azure Blob Storage.
*
* This information will tell you if the path is void, is a blob block or is
* a virtual folder.
*
* @param string $path
* The path to get information on.
*
* @return array
* An array of relevant information.
*/
protected function remotePathInfo(string $path): array {
// Returns information about whether a file exists or not.
// First, if we find a blob, that means we have ourselves a file.
$blob = $this->getBlob($path);
if ($blob !== NULL) {
return [TRUE, 'file', $blob];
}
// If the above doesn't go through, we check if it's a directory.
$folder = $this->getVirtualFolder($path);
if ($folder !== NULL) {
return [TRUE, 'folder', $folder];
}
// Otherwise, return false, wrapped in a beautiful array.
return [FALSE];
}
/**
* Delete a blob of virtual folder at a given path.
*
* Use cautiously now!
*
* @param string $path
* The path that will be deleted.
* @param array $options
* Options to affect the delete operation.
*
* @return bool
* Returns TRUE if the operation was successful, FALSE otherwise.
*/
protected function deleteRemotePath(string $path, array $options = ['recursive' => FALSE]): bool {
// Get the target.
$path = StreamWrapperManager::getTarget($path);
// First, we should check if the path is a file or directory.
$pathInfo = $this->remotePathInfo($path);
// If this remote path doesn't exist, return.
if (!$pathInfo[0]) {
return FALSE;
}
// Otherwise, we handle the case where this is a file.
if ($pathInfo[1] === 'file') {
$del = TRUE;
try {
$this->getClient()->deleteBlob(static::$container, $path);
}
catch (ServiceException|\Exception $e) {
$del = FALSE;
}
return $del;
}
// Otherwise, we handle the case where this is a folder.
if ($pathInfo[1] === 'folder') {
$items = $pathInfo[2];
// Only delete stuff if the
if (!empty($items) && $options['recursive']) {
/* @var \MicrosoftAzure\Storage\Blob\Models\Blob $blob */
foreach ($items as $item) {
if ($item instanceof Blob) {
$blobName = $blob->getName();
$this->deleteRemotePath($blobName);
} elseif ($item instanceof BlobPrefix){
$this->deleteRemotePath($item->getName());
}
}
} else {
return FALSE;
}
}
return TRUE;
}
/**
* Get the path to the configured Blob Storage.
*
* This takes the configurations straight from the site settings instead of
* querying the blob itself. It saves some performance.
*
* @param bool $includeContainer
* Set this to TRUE to include the configured container in the path.
*
* @return string
* The URL to the Blob Storage.
*/
protected function getStoragePath($includeContainer = false): string {
// @TODO - Document your shit!
return static::$blobStorageUrl . ($includeContainer ? '/' . static::$container : '');
}
/**
* Extract container name from a given path.
*
* @param string $path
* External path to a file in the blob storage.
*
* @return string
* The container that this file is found in. Usually the first subdirectory
* level in a given URL towards the storage.
*/
protected function extractContainerNameFromRemotePath($path): string {
$url = parse_url($path);
if ($url['host']) {
return $url['host'];
}
return '';
}
/**
* Extract file name from a given path.
*
* @param string $path
* The path to a file in the storage.
*
* @return string
* The file path stripped of the host and domain.
*/
protected function extractFileNameFromRemotePath(string $path): string {
$url = parse_url($path);
if ($url['host']) {
$fileName = $url['path'] ?? $url['host'];
if (strpos($fileName, '/') === 0) {
$fileName = substr($fileName, 1);
}
return $fileName;
}
return '';
}
}
