From 53954efb1467347d5ce4a56ce9167f1ae1b458c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Langenk=C3=A4mper?= Date: Wed, 26 Nov 2025 14:05:06 +0100 Subject: [PATCH 01/17] initial azure storage disk implementation --- src/AzureBlobStorageAdapter.php | 63 ++++++++++++ src/AzureFilesystemAdapter.php | 37 +++++++ .../Controllers/Api/UserDiskController.php | 2 +- src/UserDisk.php | 1 + src/UserDisksServiceProvider.php | 44 +++++++++ src/config/user_disks.php | 28 ++++++ .../views/manual/tutorials/about.blade.php | 9 ++ .../views/manual/types/azure.blade.php | 55 +++++++++++ src/resources/views/store/azure.blade.php | 97 +++++++++++++++++++ src/resources/views/update/azure.blade.php | 97 +++++++++++++++++++ 10 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 src/AzureBlobStorageAdapter.php create mode 100644 src/AzureFilesystemAdapter.php create mode 100644 src/resources/views/manual/types/azure.blade.php create mode 100644 src/resources/views/store/azure.blade.php create mode 100644 src/resources/views/update/azure.blade.php diff --git a/src/AzureBlobStorageAdapter.php b/src/AzureBlobStorageAdapter.php new file mode 100644 index 0000000..e29361d --- /dev/null +++ b/src/AzureBlobStorageAdapter.php @@ -0,0 +1,63 @@ +path(); + + // Normalize paths to ensure correct comparison + $itemPath = trim($itemPath, '/'); + $cleanPath = trim($path, '/'); + + // Ensure the item is actually within the requested path + if ($cleanPath !== '' && !str_starts_with($itemPath, $cleanPath . '/')) { + continue; + } + + // Calculate relative path + $relativePath = $cleanPath === '' ? $itemPath : substr($itemPath, strlen($cleanPath) + 1); + + if (str_contains($relativePath, '/')) { + // It's in a subdirectory + $parts = explode('/', $relativePath); + $dirName = $parts[0]; + $fullDirPath = $cleanPath === '' ? $dirName : $cleanPath . '/' . $dirName; + + if (!isset($seenDirectories[$fullDirPath])) { + $seenDirectories[$fullDirPath] = true; + yield new DirectoryAttributes($fullDirPath); + } + } else { + // It's a file in the current directory + yield $attributes; + } + } + } +} diff --git a/src/AzureFilesystemAdapter.php b/src/AzureFilesystemAdapter.php new file mode 100644 index 0000000..d35f7dc --- /dev/null +++ b/src/AzureFilesystemAdapter.php @@ -0,0 +1,37 @@ +config['url'])) { + $url = $this->concatPathToUrl($this->config['url'], $path); + } else { + $url = $this->concatPathToUrl($this->config['endpoint'] ?? '', $this->config['container'].'/'.$path); + } + + if (!empty($this->config['sas_token'])) { + $sas = $this->config['sas_token']; + // Ensure SAS token starts with ? if not present and url doesn't have query + if (!str_contains($sas, '?') && !str_contains($url, '?')) { + $sas = '?'.$sas; + } elseif (str_contains($url, '?') && str_starts_with($sas, '?')) { + $sas = '&'.substr($sas, 1); + } + + $url .= $sas; + } + + return $url; + } +} diff --git a/src/Http/Controllers/Api/UserDiskController.php b/src/Http/Controllers/Api/UserDiskController.php index ef728c9..b4b61e9 100644 --- a/src/Http/Controllers/Api/UserDiskController.php +++ b/src/Http/Controllers/Api/UserDiskController.php @@ -242,7 +242,7 @@ protected function validateGenericConfig(UserDisk $disk) try { $this->validateDiskAccess($disk); } catch (Exception $e) { - throw ValidationException::withMessages(['error' => 'The configuration seems to be invalid.']); + throw ValidationException::withMessages(['error' => 'The configuration seems to be invalid. ' . $e->getMessage()]); } } diff --git a/src/UserDisk.php b/src/UserDisk.php index 64d7143..17c5434 100644 --- a/src/UserDisk.php +++ b/src/UserDisk.php @@ -19,6 +19,7 @@ class UserDisk extends Model 'webdav' => 'WebDAV', 'elements' => 'Elements', 'aruna' => 'Aruna', + 'azure' => 'Azure Blob Storage', ]; /** diff --git a/src/UserDisksServiceProvider.php b/src/UserDisksServiceProvider.php index 4368bcb..7ae44f5 100644 --- a/src/UserDisksServiceProvider.php +++ b/src/UserDisksServiceProvider.php @@ -61,6 +61,7 @@ public function boot(Modules $modules, Router $router) $this->addStorageConfigResolver(); $this->overrideUseDiskGateAbility(); + $this->registerAzureDriver(); if (config('user_disks.notifications.allow_user_settings')) { $modules->registerViewMixin('user-disks', 'settings.notifications'); @@ -136,4 +137,47 @@ protected function overrideUseDiskGateAbility() return $useDiskAbility($user, $disk); }); } + + /** + * Register the Azure Blob Storage driver. + */ + protected function registerAzureDriver() + { + Storage::extend('azure', function ($app, $config) { + if (empty($config['sas_token'])) { + $endpoint = sprintf( + 'DefaultEndpointsProtocol=https;AccountName=%s;AccountKey=%s;EndpointSuffix=%s', + $config['name'], + $config['key'], + $config['endpoint_suffix'] ?? 'core.windows.net' + ); + } else { + $blobEndpoint = $config['endpoint'] ?? sprintf( + 'https://%s.blob.%s', + $config['name'], + $config['endpoint_suffix'] ?? 'core.windows.net' + ); + + $endpoint = sprintf( + 'BlobEndpoint=%s;SharedAccessSignature=%s', + $blobEndpoint, + $config['sas_token'] + ); + } + + $client = \MicrosoftAzure\Storage\Blob\BlobRestProxy::createBlobService($endpoint); + + $adapter = new AzureBlobStorageAdapter( + $client, + $config['container'], + $config['prefix'] ?? '' + ); + + return new AzureFilesystemAdapter( + new \League\Flysystem\Filesystem($adapter, $config), + $adapter, + $config + ); + }); + } } diff --git a/src/config/user_disks.php b/src/config/user_disks.php index 969748b..a08429f 100644 --- a/src/config/user_disks.php +++ b/src/config/user_disks.php @@ -62,6 +62,16 @@ 'secret' => '', 'endpoint' => '', ], + + 'azure' => [ + 'driver' => 'azure', + 'name' => '', + 'key' => '', + 'container' => '', + 'url' => '', + 'endpoint' => '', + 'sas_token' => '', + ], ], /* @@ -95,6 +105,15 @@ 'key' => 'required', 'secret' => 'required', ], + + 'azure' => [ + 'name' => 'required', + 'key' => 'required_without:sas_token', + 'container' => 'required', + 'url' => 'required|url', + 'endpoint' => 'required|url', + 'sas_token' => 'required_without:key', + ], ], /* @@ -128,6 +147,15 @@ 'key' => 'filled', 'secret' => 'filled', ], + + 'azure' => [ + 'name' => 'filled', + 'key' => 'filled', + 'container' => 'filled', + 'url' => 'filled|url', + 'endpoint' => 'filled|url', + 'sas_token' => 'filled', + ], ], /* diff --git a/src/resources/views/manual/tutorials/about.blade.php b/src/resources/views/manual/tutorials/about.blade.php index 67085f8..a7fe8a4 100644 --- a/src/resources/views/manual/tutorials/about.blade.php +++ b/src/resources/views/manual/tutorials/about.blade.php @@ -65,6 +65,11 @@ Aruna @endif + @if(in_array('azure', config('user_disks.types'))) +
  • + Azure Blob Storage +
  • + @endif @if(empty(config('user_disks.types')))
  • No types are available. Please ask your administrator for help. @@ -87,5 +92,9 @@ @if(in_array('aruna', config('user_disks.types'))) @include("user-disks::manual.types.aruna") @endif + + @if(in_array('azure', config('user_disks.types'))) + @include("user-disks::manual.types.azure") + @endif @endsection diff --git a/src/resources/views/manual/types/azure.blade.php b/src/resources/views/manual/types/azure.blade.php new file mode 100644 index 0000000..0dc9249 --- /dev/null +++ b/src/resources/views/manual/types/azure.blade.php @@ -0,0 +1,55 @@ +

    Azure Blob Storage

    + +

    + Azure Blob Storage is Microsoft's object storage solution for the cloud. You can use it to store massive amounts of unstructured data, such as text or binary data. +

    + +

    + An Azure Blob Storage disk has the following options: +

    + +
    +
    URL
    +
    +

    + The full URL to the container, including the SAS token. If you paste a valid URL here, the other fields will be automatically filled. +
    Example: https://myaccount.blob.core.windows.net/mycontainer?sv=... +

    +
    + +
    Account Name
    +
    +

    + The name of your Azure Storage account. +

    +
    + +
    Account Key
    +
    +

    + The access key for your storage account. This is optional if you provide a SAS token. +

    +
    + +
    Container
    +
    +

    + The name of the container where your files are stored. +

    +
    + +
    Endpoint
    +
    +

    + The endpoint URL of your storage account. +
    Example: https://myaccount.blob.core.windows.net +

    +
    + +
    SAS Token
    +
    +

    + A Shared Access Signature (SAS) token that grants restricted access rights to Azure Storage resources. It must start with a ?. +

    +
    +
    diff --git a/src/resources/views/store/azure.blade.php b/src/resources/views/store/azure.blade.php new file mode 100644 index 0000000..e1d86b3 --- /dev/null +++ b/src/resources/views/store/azure.blade.php @@ -0,0 +1,97 @@ +
    +
    + + +

    Paste the full SAS URL here to autofill the other fields.

    + @error('url') +

    {{$message}}

    + @enderror +
    +
    + +
    +
    + + + @error('name') +

    {{$message}}

    + @enderror +
    +
    + +
    +
    + + + @error('key') +

    {{$message}}

    + @enderror +
    +
    + +
    +
    + + + @error('container') +

    {{$message}}

    + @enderror +
    +
    + +
    +
    + + + @error('endpoint') +

    {{$message}}

    + @enderror +
    +
    + +
    +
    + + + @error('sas_token') +

    {{$message}}

    + @enderror +
    +
    + + diff --git a/src/resources/views/update/azure.blade.php b/src/resources/views/update/azure.blade.php new file mode 100644 index 0000000..cdba837 --- /dev/null +++ b/src/resources/views/update/azure.blade.php @@ -0,0 +1,97 @@ +
    +
    + + +

    Paste the full SAS URL here to autofill the other fields.

    + @error('url') +

    {{$message}}

    + @enderror +
    +
    + +
    +
    + + + @error('name') +

    {{$message}}

    + @enderror +
    +
    + +
    +
    + + + @error('key') +

    {{$message}}

    + @enderror +
    +
    + +
    +
    + + + @error('container') +

    {{$message}}

    + @enderror +
    +
    + +
    +
    + + + @error('endpoint') +

    {{$message}}

    + @enderror +
    +
    + +
    +
    + + + @error('sas_token') +

    {{$message}}

    + @enderror +
    +
    + + From 0c74240e18a6ed08e77e4b585528d4625552047b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Langenk=C3=A4mper?= Date: Thu, 27 Nov 2025 10:43:52 +0100 Subject: [PATCH 02/17] lazy load subdirs --- src/AzureBlobStorageAdapter.php | 135 +++++++++++++++++++++++--------- 1 file changed, 98 insertions(+), 37 deletions(-) diff --git a/src/AzureBlobStorageAdapter.php b/src/AzureBlobStorageAdapter.php index e29361d..d0aa051 100644 --- a/src/AzureBlobStorageAdapter.php +++ b/src/AzureBlobStorageAdapter.php @@ -5,9 +5,46 @@ use League\Flysystem\AzureBlobStorage\AzureBlobStorageAdapter as BaseAdapter; use League\Flysystem\DirectoryAttributes; use League\Flysystem\FileAttributes; +use MicrosoftAzure\Storage\Blob\BlobRestProxy; +use MicrosoftAzure\Storage\Blob\Models\ListBlobsOptions; class AzureBlobStorageAdapter extends BaseAdapter { + /** + * @var BlobRestProxy + */ + protected $client; + + /** + * @var string + */ + protected $container; + + /** + * @var string + */ + protected $prefix; + + /** + * Constructor. + * + * @param BlobRestProxy $client + * @param string $container + * @param string $prefix + */ + public function __construct(BlobRestProxy $client, string $container, string $prefix = '') + { + parent::__construct($client, $container, $prefix); + $this->client = $client; + $this->container = $container; + + if ($prefix !== '' && substr($prefix, -1) !== '/') { + $prefix .= '/'; + } + + $this->prefix = $prefix; + } + /** * @inheritDoc */ @@ -18,46 +55,70 @@ public function listContents(string $path = '', bool $deep = false): iterable return; } - // Azure Blob Storage is flat, so we simulate directories by listing everything recursively - // and then grouping the results. - $contents = parent::listContents($path, true); - $seenDirectories = []; + $location = $this->applyPathPrefix($path); + + if (strlen($location) > 0 && substr($location, -1) !== '/') { + $location .= '/'; + } + + $options = new ListBlobsOptions(); + $options->setPrefix($location); + $options->setDelimiter('/'); + // Max results per page (default is usually 5000, but good to be explicit or leave default) + // $options->setMaxResults(1000); - foreach ($contents as $attributes) { - // If the parent adapter already returns a directory, yield it. - if ($attributes instanceof DirectoryAttributes) { - yield $attributes; - continue; - } - - $itemPath = $attributes->path(); - - // Normalize paths to ensure correct comparison - $itemPath = trim($itemPath, '/'); - $cleanPath = trim($path, '/'); - - // Ensure the item is actually within the requested path - if ($cleanPath !== '' && !str_starts_with($itemPath, $cleanPath . '/')) { - continue; + $continuationToken = null; + + do { + $options->setContinuationToken($continuationToken); + $result = $this->client->listBlobs($this->container, $options); + + foreach ($result->getBlobPrefixes() as $prefix) { + $dirPath = $this->removePathPrefix($prefix->getName()); + yield new DirectoryAttributes(rtrim($dirPath, '/')); } - - // Calculate relative path - $relativePath = $cleanPath === '' ? $itemPath : substr($itemPath, strlen($cleanPath) + 1); - - if (str_contains($relativePath, '/')) { - // It's in a subdirectory - $parts = explode('/', $relativePath); - $dirName = $parts[0]; - $fullDirPath = $cleanPath === '' ? $dirName : $cleanPath . '/' . $dirName; - - if (!isset($seenDirectories[$fullDirPath])) { - $seenDirectories[$fullDirPath] = true; - yield new DirectoryAttributes($fullDirPath); + + foreach ($result->getBlobs() as $blob) { + $filePath = $this->removePathPrefix($blob->getName()); + // Skip if it matches the directory itself (virtual directory marker) + if ($filePath === '' || $filePath === $path) { + continue; } - } else { - // It's a file in the current directory - yield $attributes; + + yield new FileAttributes( + $filePath, + $blob->getProperties()->getContentLength(), + null, // visibility + $blob->getProperties()->getLastModified()->getTimestamp(), + $blob->getProperties()->getContentType() + ); } - } + + $continuationToken = $result->getContinuationToken(); + } while ($continuationToken); + } + + /** + * Apply the path prefix. + * + * @param string $path + * + * @return string + */ + protected function applyPathPrefix($path): string + { + return ltrim($this->prefix . ltrim($path, '\\/'), '\\/'); + } + + /** + * Remove the path prefix. + * + * @param string $path + * + * @return string + */ + protected function removePathPrefix($path): string + { + return substr($path, strlen($this->prefix)); } } From 1bfc764c78840fbab880b089d4902bbeffd00de1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Langenk=C3=A4mper?= Date: Thu, 27 Nov 2025 11:04:53 +0100 Subject: [PATCH 03/17] azurite/nonDelimiter compatibility --- src/AzureBlobStorageAdapter.php | 41 +++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/AzureBlobStorageAdapter.php b/src/AzureBlobStorageAdapter.php index d0aa051..eead665 100644 --- a/src/AzureBlobStorageAdapter.php +++ b/src/AzureBlobStorageAdapter.php @@ -69,13 +69,19 @@ public function listContents(string $path = '', bool $deep = false): iterable $continuationToken = null; + $seenDirs = []; + do { $options->setContinuationToken($continuationToken); $result = $this->client->listBlobs($this->container, $options); foreach ($result->getBlobPrefixes() as $prefix) { $dirPath = $this->removePathPrefix($prefix->getName()); - yield new DirectoryAttributes(rtrim($dirPath, '/')); + $dirPath = rtrim($dirPath, '/'); + if (!isset($seenDirs[$dirPath])) { + $seenDirs[$dirPath] = true; + yield new DirectoryAttributes($dirPath); + } } foreach ($result->getBlobs() as $blob) { @@ -84,14 +90,31 @@ public function listContents(string $path = '', bool $deep = false): iterable if ($filePath === '' || $filePath === $path) { continue; } - - yield new FileAttributes( - $filePath, - $blob->getProperties()->getContentLength(), - null, // visibility - $blob->getProperties()->getLastModified()->getTimestamp(), - $blob->getProperties()->getContentType() - ); + + // Check if the file is in a subdirectory relative to the requested path + $relativePath = substr($filePath, strlen($path)); + $relativePath = ltrim($relativePath, '/'); + + if (str_contains($relativePath, '/')) { + // It's in a subdirectory (Server ignored delimiter, e.g. Azurite) + $parts = explode('/', $relativePath); + $dirName = $parts[0]; + $fullDirPath = $path ? $path . '/' . $dirName : $dirName; + + if (!isset($seenDirs[$fullDirPath])) { + $seenDirs[$fullDirPath] = true; + yield new DirectoryAttributes($fullDirPath); + } + } else { + // It's a direct child file + yield new FileAttributes( + $filePath, + $blob->getProperties()->getContentLength(), + null, // visibility + $blob->getProperties()->getLastModified()->getTimestamp(), + $blob->getProperties()->getContentType() + ); + } } $continuationToken = $result->getContinuationToken(); From 776867fb3b4679466f00a3a8514fa93e42780a6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Langenk=C3=A4mper?= Date: Thu, 27 Nov 2025 14:12:08 +0100 Subject: [PATCH 04/17] test --- tests/AzureBlobStorageAdapterTest.php | 102 ++++++++++++++++++++++++++ tests/UserDiskTest.php | 38 ++++++++++ 2 files changed, 140 insertions(+) create mode 100644 tests/AzureBlobStorageAdapterTest.php diff --git a/tests/AzureBlobStorageAdapterTest.php b/tests/AzureBlobStorageAdapterTest.php new file mode 100644 index 0000000..14e61cc --- /dev/null +++ b/tests/AzureBlobStorageAdapterTest.php @@ -0,0 +1,102 @@ +shouldReceive('getBlobPrefixes')->andReturn([ + $this->createBlobPrefix('folder1/'), + ]); + $result->shouldReceive('getBlobs')->andReturn([ + $this->createBlob('file1.txt'), + ]); + $result->shouldReceive('getContinuationToken')->andReturnNull(); + + $client->shouldReceive('listBlobs')->once()->andReturn($result); + + $contents = iterator_to_array($adapter->listContents('', false)); + + $this->assertCount(2, $contents); + $this->assertInstanceOf(DirectoryAttributes::class, $contents[0]); + $this->assertEquals('folder1', $contents[0]->path()); + $this->assertInstanceOf(FileAttributes::class, $contents[1]); + $this->assertEquals('file1.txt', $contents[1]->path()); + } + + public function testListContentsShallowWithoutDelimiter() + { + // Simulate Azurite behavior where delimiter is ignored and deep files are returned + $client = Mockery::mock(BlobRestProxy::class); + $adapter = new AzureBlobStorageAdapter($client, 'container'); + + $result = Mockery::mock(ListBlobsResult::class); + $result->shouldReceive('getBlobPrefixes')->andReturn([]); + $result->shouldReceive('getBlobs')->andReturn([ + $this->createBlob('file1.txt'), + $this->createBlob('folder1/file2.txt'), // Deep file + $this->createBlob('folder1/subfolder/file3.txt'), // Deeper file + ]); + $result->shouldReceive('getContinuationToken')->andReturnNull(); + + $client->shouldReceive('listBlobs')->once()->andReturn($result); + + $contents = iterator_to_array($adapter->listContents('', false)); + + // Should return file1.txt and folder1 (derived from folder1/file2.txt) + $this->assertCount(2, $contents); + + // Order depends on implementation, but let's check existence + $paths = array_map(fn($item) => $item->path(), $contents); + $this->assertContains('file1.txt', $paths); + $this->assertContains('folder1', $paths); + + $types = array_map(fn($item) => get_class($item), $contents); + $this->assertContains(FileAttributes::class, $types); + $this->assertContains(DirectoryAttributes::class, $types); + } + + protected function createBlobPrefix($name) + { + $prefix = Mockery::mock(BlobPrefix::class); + $prefix->shouldReceive('getName')->andReturn($name); + return $prefix; + } + + protected function createBlob($name) + { + $blob = Mockery::mock(Blob::class); + $blob->shouldReceive('getName')->andReturn($name); + + $properties = Mockery::mock(BlobProperties::class); + $properties->shouldReceive('getContentLength')->andReturn(100); + $properties->shouldReceive('getLastModified')->andReturn(new \DateTime()); + $properties->shouldReceive('getContentType')->andReturn('text/plain'); + + $blob->shouldReceive('getProperties')->andReturn($properties); + + return $blob; + } +} diff --git a/tests/UserDiskTest.php b/tests/UserDiskTest.php index d60529e..22b3de2 100644 --- a/tests/UserDiskTest.php +++ b/tests/UserDiskTest.php @@ -125,6 +125,34 @@ public function testGetS3Config() $this->assertEquals($expect, $disk->getConfig()); } + public function testGetAzureConfig() + { + $disk = UserDisk::factory()->make([ + 'type' => 'azure', + 'options' => [ + 'name' => 'account-name', + 'key' => 'account-key', + 'container' => 'container-name', + 'url' => 'https://account.blob.core.windows.net/container', + 'endpoint' => 'https://account.blob.core.windows.net', + 'sas_token' => '?sv=...', + ], + ]); + + $expect = [ + 'driver' => 'azure', + 'name' => 'account-name', + 'key' => 'account-key', + 'container' => 'container-name', + 'url' => 'https://account.blob.core.windows.net/container', + 'endpoint' => 'https://account.blob.core.windows.net', + 'sas_token' => '?sv=...', + 'read-only' => true, + ]; + + $this->assertEquals($expect, $disk->getConfig()); + } + public function testGetConfigTemplateDoesNotExist() { $this->expectException(\TypeError::class); @@ -148,6 +176,11 @@ public function testGetStoreValidationRulesS3() $this->assertNotEmpty(UserDisk::getStoreValidationRules('s3')); } + public function testGetStoreValidationRulesAzure() + { + $this->assertNotEmpty(UserDisk::getStoreValidationRules('azure')); + } + public function testGetUpdateValidationRules() { $rules = [ @@ -164,6 +197,11 @@ public function testGetUpdateValidationRulesS3() $this->assertNotEmpty(UserDisk::getUpdateValidationRules('s3')); } + public function testGetUpdateValidationRulesAzure() + { + $this->assertNotEmpty(UserDisk::getUpdateValidationRules('azure')); + } + public function testIsAboutToExpire() { config(['user_disks.about_to_expire_weeks' => 4]); From 0478d6f79ddc5060cddea605dcde9c417bacbcff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Langenk=C3=A4mper?= Date: Thu, 27 Nov 2025 20:12:58 +0100 Subject: [PATCH 05/17] change backend --- src/AzureBlobStorageAdapter.php | 192 +++++++++++++++----------- src/UserDisksServiceProvider.php | 46 ++++-- tests/AzureBlobStorageAdapterTest.php | 102 -------------- 3 files changed, 146 insertions(+), 194 deletions(-) delete mode 100644 tests/AzureBlobStorageAdapterTest.php diff --git a/src/AzureBlobStorageAdapter.php b/src/AzureBlobStorageAdapter.php index eead665..3da53d4 100644 --- a/src/AzureBlobStorageAdapter.php +++ b/src/AzureBlobStorageAdapter.php @@ -2,57 +2,117 @@ namespace Biigle\Modules\UserDisks; -use League\Flysystem\AzureBlobStorage\AzureBlobStorageAdapter as BaseAdapter; +use AzureOss\FlysystemAzureBlobStorage\AzureBlobStorageAdapter as BaseAdapter; +use AzureOss\Storage\Blob\BlobContainerClient; +use AzureOss\Storage\Blob\Models\Blob; +use AzureOss\Storage\Blob\Models\BlobPrefix; +use League\Flysystem\Config; use League\Flysystem\DirectoryAttributes; use League\Flysystem\FileAttributes; -use MicrosoftAzure\Storage\Blob\BlobRestProxy; -use MicrosoftAzure\Storage\Blob\Models\ListBlobsOptions; +use League\Flysystem\FilesystemAdapter; -class AzureBlobStorageAdapter extends BaseAdapter +class AzureBlobStorageAdapter implements FilesystemAdapter { - /** - * @var BlobRestProxy - */ - protected $client; - - /** - * @var string - */ - protected $container; - - /** - * @var string - */ - protected $prefix; - - /** - * Constructor. - * - * @param BlobRestProxy $client - * @param string $container - * @param string $prefix - */ - public function __construct(BlobRestProxy $client, string $container, string $prefix = '') - { - parent::__construct($client, $container, $prefix); + private BaseAdapter $adapter; + private BlobContainerClient $client; + private string $prefix; + + public function __construct(BlobContainerClient $client, string $prefix = '') + { $this->client = $client; - $this->container = $container; + $this->prefix = $prefix; if ($prefix !== '' && substr($prefix, -1) !== '/') { $prefix .= '/'; } - $this->prefix = $prefix; + $this->adapter = new BaseAdapter($client, $prefix); + } + + public function fileExists(string $path): bool + { + return $this->adapter->fileExists($path); + } + + public function directoryExists(string $path): bool + { + return $this->adapter->directoryExists($path); + } + + public function write(string $path, string $contents, Config $config): void + { + $this->adapter->write($path, $contents, $config); + } + + public function writeStream(string $path, $contents, Config $config): void + { + $this->adapter->writeStream($path, $contents, $config); + } + + public function read(string $path): string + { + return $this->adapter->read($path); } - /** - * @inheritDoc - */ - public function listContents(string $path = '', bool $deep = false): iterable + public function readStream(string $path) + { + return $this->adapter->readStream($path); + } + + public function delete(string $path): void + { + $this->adapter->delete($path); + } + + public function deleteDirectory(string $path): void + { + $this->adapter->deleteDirectory($path); + } + + public function createDirectory(string $path, Config $config): void + { + $this->adapter->createDirectory($path, $config); + } + + public function setVisibility(string $path, string $visibility): void + { + $this->adapter->setVisibility($path, $visibility); + } + + public function visibility(string $path): FileAttributes + { + return $this->adapter->visibility($path); + } + + public function mimeType(string $path): FileAttributes + { + return $this->adapter->mimeType($path); + } + + public function lastModified(string $path): FileAttributes + { + return $this->adapter->lastModified($path); + } + + public function fileSize(string $path): FileAttributes + { + return $this->adapter->fileSize($path); + } + + public function move(string $source, string $destination, Config $config): void + { + $this->adapter->move($source, $destination, $config); + } + + public function copy(string $source, string $destination, Config $config): void + { + $this->adapter->copy($source, $destination, $config); + } + + public function listContents(string $path, bool $deep): iterable { if ($deep) { - yield from parent::listContents($path, true); - return; + return $this->adapter->listContents($path, true); } $location = $this->applyPathPrefix($path); @@ -61,42 +121,31 @@ public function listContents(string $path = '', bool $deep = false): iterable $location .= '/'; } - $options = new ListBlobsOptions(); - $options->setPrefix($location); - $options->setDelimiter('/'); - // Max results per page (default is usually 5000, but good to be explicit or leave default) - // $options->setMaxResults(1000); - - $continuationToken = null; - + // Use getBlobsByHierarchy for shallow listing + $generator = $this->client->getBlobsByHierarchy($location, '/'); $seenDirs = []; - do { - $options->setContinuationToken($continuationToken); - $result = $this->client->listBlobs($this->container, $options); - - foreach ($result->getBlobPrefixes() as $prefix) { - $dirPath = $this->removePathPrefix($prefix->getName()); + foreach ($generator as $item) { + if ($item instanceof BlobPrefix) { + $dirPath = $this->removePathPrefix($item->name); $dirPath = rtrim($dirPath, '/'); if (!isset($seenDirs[$dirPath])) { $seenDirs[$dirPath] = true; yield new DirectoryAttributes($dirPath); } - } - - foreach ($result->getBlobs() as $blob) { - $filePath = $this->removePathPrefix($blob->getName()); - // Skip if it matches the directory itself (virtual directory marker) + } elseif ($item instanceof Blob) { + $filePath = $this->removePathPrefix($item->name); + if ($filePath === '' || $filePath === $path) { continue; } - // Check if the file is in a subdirectory relative to the requested path + // Azurite compatibility: Check for deep files in shallow listing $relativePath = substr($filePath, strlen($path)); $relativePath = ltrim($relativePath, '/'); if (str_contains($relativePath, '/')) { - // It's in a subdirectory (Server ignored delimiter, e.g. Azurite) + // It's in a subdirectory (Server ignored delimiter) $parts = explode('/', $relativePath); $dirName = $parts[0]; $fullDirPath = $path ? $path . '/' . $dirName : $dirName; @@ -106,40 +155,23 @@ public function listContents(string $path = '', bool $deep = false): iterable yield new DirectoryAttributes($fullDirPath); } } else { - // It's a direct child file yield new FileAttributes( $filePath, - $blob->getProperties()->getContentLength(), - null, // visibility - $blob->getProperties()->getLastModified()->getTimestamp(), - $blob->getProperties()->getContentType() + $item->properties->contentLength, + null, + $item->properties->lastModified->getTimestamp(), + $item->properties->contentType ); } } - - $continuationToken = $result->getContinuationToken(); - } while ($continuationToken); + } } - /** - * Apply the path prefix. - * - * @param string $path - * - * @return string - */ protected function applyPathPrefix($path): string { return ltrim($this->prefix . ltrim($path, '\\/'), '\\/'); } - /** - * Remove the path prefix. - * - * @param string $path - * - * @return string - */ protected function removePathPrefix($path): string { return substr($path, strlen($this->prefix)); diff --git a/src/UserDisksServiceProvider.php b/src/UserDisksServiceProvider.php index 7ae44f5..107ae9d 100644 --- a/src/UserDisksServiceProvider.php +++ b/src/UserDisksServiceProvider.php @@ -144,13 +144,33 @@ protected function overrideUseDiskGateAbility() protected function registerAzureDriver() { Storage::extend('azure', function ($app, $config) { + // Fallback for Azurite (devstoreaccount1) to use well-known key if missing or if SAS is failing + if ($config['name'] === 'devstoreaccount1') { + if (empty($config['key'])) { + $config['key'] = 'Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=='; + } + // Force use of Account Key for Azurite as SAS has issues with the library + $config['sas_token'] = null; + } + if (empty($config['sas_token'])) { - $endpoint = sprintf( - 'DefaultEndpointsProtocol=https;AccountName=%s;AccountKey=%s;EndpointSuffix=%s', - $config['name'], - $config['key'], - $config['endpoint_suffix'] ?? 'core.windows.net' - ); + if (!empty($config['endpoint'])) { + $scheme = parse_url($config['endpoint'], PHP_URL_SCHEME) ?: 'https'; + $connectionString = sprintf( + 'DefaultEndpointsProtocol=%s;AccountName=%s;AccountKey=%s;BlobEndpoint=%s', + $scheme, + $config['name'], + $config['key'], + $config['endpoint'] + ); + } else { + $connectionString = sprintf( + 'DefaultEndpointsProtocol=https;AccountName=%s;AccountKey=%s;EndpointSuffix=%s', + $config['name'], + $config['key'], + $config['endpoint_suffix'] ?? 'core.windows.net' + ); + } } else { $blobEndpoint = $config['endpoint'] ?? sprintf( 'https://%s.blob.%s', @@ -158,18 +178,20 @@ protected function registerAzureDriver() $config['endpoint_suffix'] ?? 'core.windows.net' ); - $endpoint = sprintf( - 'BlobEndpoint=%s;SharedAccessSignature=%s', + $scheme = parse_url($blobEndpoint, PHP_URL_SCHEME) ?: 'https'; + $connectionString = sprintf( + 'BlobEndpoint=%s;SharedAccessSignature=%s;DefaultEndpointsProtocol=%s', $blobEndpoint, - $config['sas_token'] + ltrim($config['sas_token'], '?'), + $scheme ); } - $client = \MicrosoftAzure\Storage\Blob\BlobRestProxy::createBlobService($endpoint); + $serviceClient = \AzureOss\Storage\Blob\BlobServiceClient::fromConnectionString($connectionString); + $containerClient = $serviceClient->getContainerClient($config['container']); $adapter = new AzureBlobStorageAdapter( - $client, - $config['container'], + $containerClient, $config['prefix'] ?? '' ); diff --git a/tests/AzureBlobStorageAdapterTest.php b/tests/AzureBlobStorageAdapterTest.php deleted file mode 100644 index 14e61cc..0000000 --- a/tests/AzureBlobStorageAdapterTest.php +++ /dev/null @@ -1,102 +0,0 @@ -shouldReceive('getBlobPrefixes')->andReturn([ - $this->createBlobPrefix('folder1/'), - ]); - $result->shouldReceive('getBlobs')->andReturn([ - $this->createBlob('file1.txt'), - ]); - $result->shouldReceive('getContinuationToken')->andReturnNull(); - - $client->shouldReceive('listBlobs')->once()->andReturn($result); - - $contents = iterator_to_array($adapter->listContents('', false)); - - $this->assertCount(2, $contents); - $this->assertInstanceOf(DirectoryAttributes::class, $contents[0]); - $this->assertEquals('folder1', $contents[0]->path()); - $this->assertInstanceOf(FileAttributes::class, $contents[1]); - $this->assertEquals('file1.txt', $contents[1]->path()); - } - - public function testListContentsShallowWithoutDelimiter() - { - // Simulate Azurite behavior where delimiter is ignored and deep files are returned - $client = Mockery::mock(BlobRestProxy::class); - $adapter = new AzureBlobStorageAdapter($client, 'container'); - - $result = Mockery::mock(ListBlobsResult::class); - $result->shouldReceive('getBlobPrefixes')->andReturn([]); - $result->shouldReceive('getBlobs')->andReturn([ - $this->createBlob('file1.txt'), - $this->createBlob('folder1/file2.txt'), // Deep file - $this->createBlob('folder1/subfolder/file3.txt'), // Deeper file - ]); - $result->shouldReceive('getContinuationToken')->andReturnNull(); - - $client->shouldReceive('listBlobs')->once()->andReturn($result); - - $contents = iterator_to_array($adapter->listContents('', false)); - - // Should return file1.txt and folder1 (derived from folder1/file2.txt) - $this->assertCount(2, $contents); - - // Order depends on implementation, but let's check existence - $paths = array_map(fn($item) => $item->path(), $contents); - $this->assertContains('file1.txt', $paths); - $this->assertContains('folder1', $paths); - - $types = array_map(fn($item) => get_class($item), $contents); - $this->assertContains(FileAttributes::class, $types); - $this->assertContains(DirectoryAttributes::class, $types); - } - - protected function createBlobPrefix($name) - { - $prefix = Mockery::mock(BlobPrefix::class); - $prefix->shouldReceive('getName')->andReturn($name); - return $prefix; - } - - protected function createBlob($name) - { - $blob = Mockery::mock(Blob::class); - $blob->shouldReceive('getName')->andReturn($name); - - $properties = Mockery::mock(BlobProperties::class); - $properties->shouldReceive('getContentLength')->andReturn(100); - $properties->shouldReceive('getLastModified')->andReturn(new \DateTime()); - $properties->shouldReceive('getContentType')->andReturn('text/plain'); - - $blob->shouldReceive('getProperties')->andReturn($properties); - - return $blob; - } -} From 3df47355345c84de29bef99fce99a48a33e1c5f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Langenk=C3=A4mper?= Date: Fri, 28 Nov 2025 22:37:07 +0100 Subject: [PATCH 06/17] Replaces 'azure' disk type with 'azure-storage-blob' and updating related views and configuration. --- composer.json | 70 +++---- src/AzureBlobStorageAdapter.php | 179 ------------------ src/AzureFilesystemAdapter.php | 37 ---- src/UserDisk.php | 2 +- src/UserDisksServiceProvider.php | 66 ------- src/config/user_disks.php | 26 +-- .../manual/types/azure-storage-blob.blade.php | 30 +++ .../views/manual/types/azure.blade.php | 55 ------ .../views/store/azure-storage-blob.blade.php | 90 +++++++++ src/resources/views/store/azure.blade.php | 97 ---------- .../views/update/azure-storage-blob.blade.php | 90 +++++++++ src/resources/views/update/azure.blade.php | 97 ---------- 12 files changed, 255 insertions(+), 584 deletions(-) delete mode 100644 src/AzureBlobStorageAdapter.php delete mode 100644 src/AzureFilesystemAdapter.php create mode 100644 src/resources/views/manual/types/azure-storage-blob.blade.php delete mode 100644 src/resources/views/manual/types/azure.blade.php create mode 100644 src/resources/views/store/azure-storage-blob.blade.php delete mode 100644 src/resources/views/store/azure.blade.php create mode 100644 src/resources/views/update/azure-storage-blob.blade.php delete mode 100644 src/resources/views/update/azure.blade.php diff --git a/composer.json b/composer.json index aefeb02..c9508e4 100644 --- a/composer.json +++ b/composer.json @@ -1,35 +1,39 @@ { - "name": "biigle/user-disks", - "description": "BIIGLE module to offer private storage disks for users.", - "keywords": ["biigle", "biigle-module"], - "license": "GPL-3.0-only", - "support": { - "source": "https://github.com/biigle/user-disks", - "issues": "https://github.com/biigle/user-disks/issues" - }, - "homepage": "https://biigle.de", - "authors": [ - { - "name": "Martin Zurowietz", - "email": "m.zurowietz@uni-bielefeld.de" - } - ], - "require": { - "biigle/laravel-elements-storage": "^2.2", - "league/flysystem-aws-s3-v3": "^3.12", - "league/flysystem-read-only": "^3.3", - "biigle/laravel-webdav": "^1.0" - }, - "autoload": { - "psr-4": { - "Biigle\\Modules\\UserDisks\\": "src" - } - }, - "extra": { - "laravel": { - "providers": [ - "Biigle\\Modules\\UserDisks\\UserDisksServiceProvider" - ] - } + "name": "biigle/user-disks", + "description": "BIIGLE module to offer private storage disks for users.", + "keywords": [ + "biigle", + "biigle-module" + ], + "license": "GPL-3.0-only", + "support": { + "source": "https://github.com/biigle/user-disks", + "issues": "https://github.com/biigle/user-disks/issues" + }, + "homepage": "https://biigle.de", + "authors": [ + { + "name": "Martin Zurowietz", + "email": "m.zurowietz@uni-bielefeld.de" } -} + ], + "require": { + "biigle/laravel-elements-storage": "^2.2", + "league/flysystem-aws-s3-v3": "^3.12", + "league/flysystem-read-only": "^3.3", + "biigle/laravel-webdav": "^1.0", + "azure-oss/storage-blob-laravel": "^1.4" + }, + "autoload": { + "psr-4": { + "Biigle\\Modules\\UserDisks\\": "src" + } + }, + "extra": { + "laravel": { + "providers": [ + "Biigle\\Modules\\UserDisks\\UserDisksServiceProvider" + ] + } + } +} \ No newline at end of file diff --git a/src/AzureBlobStorageAdapter.php b/src/AzureBlobStorageAdapter.php deleted file mode 100644 index 3da53d4..0000000 --- a/src/AzureBlobStorageAdapter.php +++ /dev/null @@ -1,179 +0,0 @@ -client = $client; - $this->prefix = $prefix; - - if ($prefix !== '' && substr($prefix, -1) !== '/') { - $prefix .= '/'; - } - - $this->adapter = new BaseAdapter($client, $prefix); - } - - public function fileExists(string $path): bool - { - return $this->adapter->fileExists($path); - } - - public function directoryExists(string $path): bool - { - return $this->adapter->directoryExists($path); - } - - public function write(string $path, string $contents, Config $config): void - { - $this->adapter->write($path, $contents, $config); - } - - public function writeStream(string $path, $contents, Config $config): void - { - $this->adapter->writeStream($path, $contents, $config); - } - - public function read(string $path): string - { - return $this->adapter->read($path); - } - - public function readStream(string $path) - { - return $this->adapter->readStream($path); - } - - public function delete(string $path): void - { - $this->adapter->delete($path); - } - - public function deleteDirectory(string $path): void - { - $this->adapter->deleteDirectory($path); - } - - public function createDirectory(string $path, Config $config): void - { - $this->adapter->createDirectory($path, $config); - } - - public function setVisibility(string $path, string $visibility): void - { - $this->adapter->setVisibility($path, $visibility); - } - - public function visibility(string $path): FileAttributes - { - return $this->adapter->visibility($path); - } - - public function mimeType(string $path): FileAttributes - { - return $this->adapter->mimeType($path); - } - - public function lastModified(string $path): FileAttributes - { - return $this->adapter->lastModified($path); - } - - public function fileSize(string $path): FileAttributes - { - return $this->adapter->fileSize($path); - } - - public function move(string $source, string $destination, Config $config): void - { - $this->adapter->move($source, $destination, $config); - } - - public function copy(string $source, string $destination, Config $config): void - { - $this->adapter->copy($source, $destination, $config); - } - - public function listContents(string $path, bool $deep): iterable - { - if ($deep) { - return $this->adapter->listContents($path, true); - } - - $location = $this->applyPathPrefix($path); - - if (strlen($location) > 0 && substr($location, -1) !== '/') { - $location .= '/'; - } - - // Use getBlobsByHierarchy for shallow listing - $generator = $this->client->getBlobsByHierarchy($location, '/'); - $seenDirs = []; - - foreach ($generator as $item) { - if ($item instanceof BlobPrefix) { - $dirPath = $this->removePathPrefix($item->name); - $dirPath = rtrim($dirPath, '/'); - if (!isset($seenDirs[$dirPath])) { - $seenDirs[$dirPath] = true; - yield new DirectoryAttributes($dirPath); - } - } elseif ($item instanceof Blob) { - $filePath = $this->removePathPrefix($item->name); - - if ($filePath === '' || $filePath === $path) { - continue; - } - - // Azurite compatibility: Check for deep files in shallow listing - $relativePath = substr($filePath, strlen($path)); - $relativePath = ltrim($relativePath, '/'); - - if (str_contains($relativePath, '/')) { - // It's in a subdirectory (Server ignored delimiter) - $parts = explode('/', $relativePath); - $dirName = $parts[0]; - $fullDirPath = $path ? $path . '/' . $dirName : $dirName; - - if (!isset($seenDirs[$fullDirPath])) { - $seenDirs[$fullDirPath] = true; - yield new DirectoryAttributes($fullDirPath); - } - } else { - yield new FileAttributes( - $filePath, - $item->properties->contentLength, - null, - $item->properties->lastModified->getTimestamp(), - $item->properties->contentType - ); - } - } - } - } - - protected function applyPathPrefix($path): string - { - return ltrim($this->prefix . ltrim($path, '\\/'), '\\/'); - } - - protected function removePathPrefix($path): string - { - return substr($path, strlen($this->prefix)); - } -} diff --git a/src/AzureFilesystemAdapter.php b/src/AzureFilesystemAdapter.php deleted file mode 100644 index d35f7dc..0000000 --- a/src/AzureFilesystemAdapter.php +++ /dev/null @@ -1,37 +0,0 @@ -config['url'])) { - $url = $this->concatPathToUrl($this->config['url'], $path); - } else { - $url = $this->concatPathToUrl($this->config['endpoint'] ?? '', $this->config['container'].'/'.$path); - } - - if (!empty($this->config['sas_token'])) { - $sas = $this->config['sas_token']; - // Ensure SAS token starts with ? if not present and url doesn't have query - if (!str_contains($sas, '?') && !str_contains($url, '?')) { - $sas = '?'.$sas; - } elseif (str_contains($url, '?') && str_starts_with($sas, '?')) { - $sas = '&'.substr($sas, 1); - } - - $url .= $sas; - } - - return $url; - } -} diff --git a/src/UserDisk.php b/src/UserDisk.php index 17c5434..963e2c8 100644 --- a/src/UserDisk.php +++ b/src/UserDisk.php @@ -19,7 +19,7 @@ class UserDisk extends Model 'webdav' => 'WebDAV', 'elements' => 'Elements', 'aruna' => 'Aruna', - 'azure' => 'Azure Blob Storage', + 'azure-storage-blob' => 'Azure Blob Storage', ]; /** diff --git a/src/UserDisksServiceProvider.php b/src/UserDisksServiceProvider.php index 107ae9d..4368bcb 100644 --- a/src/UserDisksServiceProvider.php +++ b/src/UserDisksServiceProvider.php @@ -61,7 +61,6 @@ public function boot(Modules $modules, Router $router) $this->addStorageConfigResolver(); $this->overrideUseDiskGateAbility(); - $this->registerAzureDriver(); if (config('user_disks.notifications.allow_user_settings')) { $modules->registerViewMixin('user-disks', 'settings.notifications'); @@ -137,69 +136,4 @@ protected function overrideUseDiskGateAbility() return $useDiskAbility($user, $disk); }); } - - /** - * Register the Azure Blob Storage driver. - */ - protected function registerAzureDriver() - { - Storage::extend('azure', function ($app, $config) { - // Fallback for Azurite (devstoreaccount1) to use well-known key if missing or if SAS is failing - if ($config['name'] === 'devstoreaccount1') { - if (empty($config['key'])) { - $config['key'] = 'Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=='; - } - // Force use of Account Key for Azurite as SAS has issues with the library - $config['sas_token'] = null; - } - - if (empty($config['sas_token'])) { - if (!empty($config['endpoint'])) { - $scheme = parse_url($config['endpoint'], PHP_URL_SCHEME) ?: 'https'; - $connectionString = sprintf( - 'DefaultEndpointsProtocol=%s;AccountName=%s;AccountKey=%s;BlobEndpoint=%s', - $scheme, - $config['name'], - $config['key'], - $config['endpoint'] - ); - } else { - $connectionString = sprintf( - 'DefaultEndpointsProtocol=https;AccountName=%s;AccountKey=%s;EndpointSuffix=%s', - $config['name'], - $config['key'], - $config['endpoint_suffix'] ?? 'core.windows.net' - ); - } - } else { - $blobEndpoint = $config['endpoint'] ?? sprintf( - 'https://%s.blob.%s', - $config['name'], - $config['endpoint_suffix'] ?? 'core.windows.net' - ); - - $scheme = parse_url($blobEndpoint, PHP_URL_SCHEME) ?: 'https'; - $connectionString = sprintf( - 'BlobEndpoint=%s;SharedAccessSignature=%s;DefaultEndpointsProtocol=%s', - $blobEndpoint, - ltrim($config['sas_token'], '?'), - $scheme - ); - } - - $serviceClient = \AzureOss\Storage\Blob\BlobServiceClient::fromConnectionString($connectionString); - $containerClient = $serviceClient->getContainerClient($config['container']); - - $adapter = new AzureBlobStorageAdapter( - $containerClient, - $config['prefix'] ?? '' - ); - - return new AzureFilesystemAdapter( - new \League\Flysystem\Filesystem($adapter, $config), - $adapter, - $config - ); - }); - } } diff --git a/src/config/user_disks.php b/src/config/user_disks.php index a08429f..31e5f7f 100644 --- a/src/config/user_disks.php +++ b/src/config/user_disks.php @@ -63,14 +63,10 @@ 'endpoint' => '', ], - 'azure' => [ - 'driver' => 'azure', - 'name' => '', - 'key' => '', + 'azure-storage-blob' => [ + 'driver' => 'azure-storage-blob', + 'connection_string' => '', 'container' => '', - 'url' => '', - 'endpoint' => '', - 'sas_token' => '', ], ], @@ -106,13 +102,9 @@ 'secret' => 'required', ], - 'azure' => [ - 'name' => 'required', - 'key' => 'required_without:sas_token', + 'azure-storage-blob' => [ + 'connection_string' => 'required', 'container' => 'required', - 'url' => 'required|url', - 'endpoint' => 'required|url', - 'sas_token' => 'required_without:key', ], ], @@ -148,13 +140,9 @@ 'secret' => 'filled', ], - 'azure' => [ - 'name' => 'filled', - 'key' => 'filled', + 'azure-storage-blob' => [ + 'connection_string' => 'filled', 'container' => 'filled', - 'url' => 'filled|url', - 'endpoint' => 'filled|url', - 'sas_token' => 'filled', ], ], diff --git a/src/resources/views/manual/types/azure-storage-blob.blade.php b/src/resources/views/manual/types/azure-storage-blob.blade.php new file mode 100644 index 0000000..69f9f88 --- /dev/null +++ b/src/resources/views/manual/types/azure-storage-blob.blade.php @@ -0,0 +1,30 @@ +

    Azure Blob Storage

    + +

    + Azure Blob Storage is Microsoft's object storage solution for the cloud. You can use it to store massive amounts of unstructured data, such as text or binary data. +

    + +

    + An Azure Blob Storage disk has the following options: +

    + +
    +
    Connection String
    +
    +

    + The Azure Storage connection string. You can find this in the Azure Portal under your Storage Account → Access keys. +
    Example: DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=...;EndpointSuffix=core.windows.net +

    +

    + For local development with Azurite, use: +
    DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1; +

    +
    + +
    Container
    +
    +

    + The name of the container where your files are stored. +

    +
    +
    diff --git a/src/resources/views/manual/types/azure.blade.php b/src/resources/views/manual/types/azure.blade.php deleted file mode 100644 index 0dc9249..0000000 --- a/src/resources/views/manual/types/azure.blade.php +++ /dev/null @@ -1,55 +0,0 @@ -

    Azure Blob Storage

    - -

    - Azure Blob Storage is Microsoft's object storage solution for the cloud. You can use it to store massive amounts of unstructured data, such as text or binary data. -

    - -

    - An Azure Blob Storage disk has the following options: -

    - -
    -
    URL
    -
    -

    - The full URL to the container, including the SAS token. If you paste a valid URL here, the other fields will be automatically filled. -
    Example: https://myaccount.blob.core.windows.net/mycontainer?sv=... -

    -
    - -
    Account Name
    -
    -

    - The name of your Azure Storage account. -

    -
    - -
    Account Key
    -
    -

    - The access key for your storage account. This is optional if you provide a SAS token. -

    -
    - -
    Container
    -
    -

    - The name of the container where your files are stored. -

    -
    - -
    Endpoint
    -
    -

    - The endpoint URL of your storage account. -
    Example: https://myaccount.blob.core.windows.net -

    -
    - -
    SAS Token
    -
    -

    - A Shared Access Signature (SAS) token that grants restricted access rights to Azure Storage resources. It must start with a ?. -

    -
    -
    diff --git a/src/resources/views/store/azure-storage-blob.blade.php b/src/resources/views/store/azure-storage-blob.blade.php new file mode 100644 index 0000000..862a2b9 --- /dev/null +++ b/src/resources/views/store/azure-storage-blob.blade.php @@ -0,0 +1,90 @@ +
    +
    + + +

    + Paste your full SAS URL here to auto-fill the connection string and container fields below. +

    + @error('sas_url') +

    {{$message}}

    + @enderror +
    +
    + +
    +
    + + +

    + Will be autofilled if SAS URL is given. You can find the connection string in the Azure Portal under your Storage Account → Security + networking → Access keys. +
    Example: DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net +

    + @error('connection_string') +

    {{$message}}

    + @enderror +
    +
    + +
    +
    + + + @error('container') +

    {{$message}}

    + @enderror +
    +
    + + diff --git a/src/resources/views/store/azure.blade.php b/src/resources/views/store/azure.blade.php deleted file mode 100644 index e1d86b3..0000000 --- a/src/resources/views/store/azure.blade.php +++ /dev/null @@ -1,97 +0,0 @@ -
    -
    - - -

    Paste the full SAS URL here to autofill the other fields.

    - @error('url') -

    {{$message}}

    - @enderror -
    -
    - -
    -
    - - - @error('name') -

    {{$message}}

    - @enderror -
    -
    - -
    -
    - - - @error('key') -

    {{$message}}

    - @enderror -
    -
    - -
    -
    - - - @error('container') -

    {{$message}}

    - @enderror -
    -
    - -
    -
    - - - @error('endpoint') -

    {{$message}}

    - @enderror -
    -
    - -
    -
    - - - @error('sas_token') -

    {{$message}}

    - @enderror -
    -
    - - diff --git a/src/resources/views/update/azure-storage-blob.blade.php b/src/resources/views/update/azure-storage-blob.blade.php new file mode 100644 index 0000000..bb237e0 --- /dev/null +++ b/src/resources/views/update/azure-storage-blob.blade.php @@ -0,0 +1,90 @@ +
    +
    + + +

    + Paste your full SAS URL here to auto-fill the connection string and container fields below. +

    + @error('sas_url') +

    {{$message}}

    + @enderror +
    +
    + +
    +
    + + +

    + Will be autofilled if SAS URL is given. You can find the connection string in the Azure Portal under your Storage Account → Security + networking → Access keys. Leave empty to keep the current value. +

    + @error('connection_string') +

    {{$message}}

    + @enderror +
    +
    + +
    +
    + + +

    Leave empty to keep the current value.

    + @error('container') +

    {{$message}}

    + @enderror +
    +
    + + diff --git a/src/resources/views/update/azure.blade.php b/src/resources/views/update/azure.blade.php deleted file mode 100644 index cdba837..0000000 --- a/src/resources/views/update/azure.blade.php +++ /dev/null @@ -1,97 +0,0 @@ -
    -
    - - -

    Paste the full SAS URL here to autofill the other fields.

    - @error('url') -

    {{$message}}

    - @enderror -
    -
    - -
    -
    - - - @error('name') -

    {{$message}}

    - @enderror -
    -
    - -
    -
    - - - @error('key') -

    {{$message}}

    - @enderror -
    -
    - -
    -
    - - - @error('container') -

    {{$message}}

    - @enderror -
    -
    - -
    -
    - - - @error('endpoint') -

    {{$message}}

    - @enderror -
    -
    - -
    -
    - - - @error('sas_token') -

    {{$message}}

    - @enderror -
    -
    - - From fda13cf03fcda10097e084001fb90844420e9813 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 2 Dec 2025 10:23:30 +0100 Subject: [PATCH 07/17] Refactor implementation --- composer.json | 76 ++++++------ .../Controllers/Api/UserDiskController.php | 2 +- src/UserDisk.php | 2 +- src/config/user_disks.php | 9 +- ...storage-blob.blade.php => azure.blade.php} | 4 +- .../views/store/azure-storage-blob.blade.php | 90 --------------- src/resources/views/store/azure.blade.php | 108 +++++++++++++++++ .../views/update/azure-storage-blob.blade.php | 90 --------------- src/resources/views/update/azure.blade.php | 109 ++++++++++++++++++ 9 files changed, 264 insertions(+), 226 deletions(-) rename src/resources/views/manual/types/{azure-storage-blob.blade.php => azure.blade.php} (85%) delete mode 100644 src/resources/views/store/azure-storage-blob.blade.php create mode 100644 src/resources/views/store/azure.blade.php delete mode 100644 src/resources/views/update/azure-storage-blob.blade.php create mode 100644 src/resources/views/update/azure.blade.php diff --git a/composer.json b/composer.json index c9508e4..14a7735 100644 --- a/composer.json +++ b/composer.json @@ -1,39 +1,39 @@ { - "name": "biigle/user-disks", - "description": "BIIGLE module to offer private storage disks for users.", - "keywords": [ - "biigle", - "biigle-module" - ], - "license": "GPL-3.0-only", - "support": { - "source": "https://github.com/biigle/user-disks", - "issues": "https://github.com/biigle/user-disks/issues" - }, - "homepage": "https://biigle.de", - "authors": [ - { - "name": "Martin Zurowietz", - "email": "m.zurowietz@uni-bielefeld.de" - } - ], - "require": { - "biigle/laravel-elements-storage": "^2.2", - "league/flysystem-aws-s3-v3": "^3.12", - "league/flysystem-read-only": "^3.3", - "biigle/laravel-webdav": "^1.0", - "azure-oss/storage-blob-laravel": "^1.4" - }, - "autoload": { - "psr-4": { - "Biigle\\Modules\\UserDisks\\": "src" - } - }, - "extra": { - "laravel": { - "providers": [ - "Biigle\\Modules\\UserDisks\\UserDisksServiceProvider" - ] - } - } -} \ No newline at end of file + "name": "biigle/user-disks", + "description": "BIIGLE module to offer private storage disks for users.", + "keywords": [ + "biigle", + "biigle-module" + ], + "license": "GPL-3.0-only", + "support": { + "source": "https://github.com/biigle/user-disks", + "issues": "https://github.com/biigle/user-disks/issues" + }, + "homepage": "https://biigle.de", + "authors": [ + { + "name": "Martin Zurowietz", + "email": "m.zurowietz@uni-bielefeld.de" + } + ], + "require": { + "biigle/laravel-elements-storage": "^2.2", + "league/flysystem-aws-s3-v3": "^3.12", + "league/flysystem-read-only": "^3.3", + "biigle/laravel-webdav": "^1.0", + "azure-oss/storage-blob-laravel": "^1.4" + }, + "autoload": { + "psr-4": { + "Biigle\\Modules\\UserDisks\\": "src" + } + }, + "extra": { + "laravel": { + "providers": [ + "Biigle\\Modules\\UserDisks\\UserDisksServiceProvider" + ] + } + } +} diff --git a/src/Http/Controllers/Api/UserDiskController.php b/src/Http/Controllers/Api/UserDiskController.php index b4b61e9..ef728c9 100644 --- a/src/Http/Controllers/Api/UserDiskController.php +++ b/src/Http/Controllers/Api/UserDiskController.php @@ -242,7 +242,7 @@ protected function validateGenericConfig(UserDisk $disk) try { $this->validateDiskAccess($disk); } catch (Exception $e) { - throw ValidationException::withMessages(['error' => 'The configuration seems to be invalid. ' . $e->getMessage()]); + throw ValidationException::withMessages(['error' => 'The configuration seems to be invalid.']); } } diff --git a/src/UserDisk.php b/src/UserDisk.php index 963e2c8..17c5434 100644 --- a/src/UserDisk.php +++ b/src/UserDisk.php @@ -19,7 +19,7 @@ class UserDisk extends Model 'webdav' => 'WebDAV', 'elements' => 'Elements', 'aruna' => 'Aruna', - 'azure-storage-blob' => 'Azure Blob Storage', + 'azure' => 'Azure Blob Storage', ]; /** diff --git a/src/config/user_disks.php b/src/config/user_disks.php index 31e5f7f..67c0336 100644 --- a/src/config/user_disks.php +++ b/src/config/user_disks.php @@ -2,7 +2,8 @@ return [ /* - | Available types for new storage disks. Supported are: s3, webdav, elements, aruna. + | Available types for new storage disks. Supported are: s3, webdav, elements, aruna, + | azure. */ 'types' => array_filter(explode(',', env('USER_DISKS_TYPES', 's3'))), @@ -63,7 +64,7 @@ 'endpoint' => '', ], - 'azure-storage-blob' => [ + 'azure' => [ 'driver' => 'azure-storage-blob', 'connection_string' => '', 'container' => '', @@ -102,7 +103,7 @@ 'secret' => 'required', ], - 'azure-storage-blob' => [ + 'azure' => [ 'connection_string' => 'required', 'container' => 'required', ], @@ -140,7 +141,7 @@ 'secret' => 'filled', ], - 'azure-storage-blob' => [ + 'azure' => [ 'connection_string' => 'filled', 'container' => 'filled', ], diff --git a/src/resources/views/manual/types/azure-storage-blob.blade.php b/src/resources/views/manual/types/azure.blade.php similarity index 85% rename from src/resources/views/manual/types/azure-storage-blob.blade.php rename to src/resources/views/manual/types/azure.blade.php index 69f9f88..df2a8b3 100644 --- a/src/resources/views/manual/types/azure-storage-blob.blade.php +++ b/src/resources/views/manual/types/azure.blade.php @@ -1,7 +1,7 @@ -

    Azure Blob Storage

    +

    Azure Blob Storage

    - Azure Blob Storage is Microsoft's object storage solution for the cloud. You can use it to store massive amounts of unstructured data, such as text or binary data. + Azure Blob Storage is Microsoft's object storage solution for the cloud. An Azure storage disk can connect to one storage container in Azure.

    diff --git a/src/resources/views/store/azure-storage-blob.blade.php b/src/resources/views/store/azure-storage-blob.blade.php deleted file mode 100644 index 862a2b9..0000000 --- a/src/resources/views/store/azure-storage-blob.blade.php +++ /dev/null @@ -1,90 +0,0 @@ -

    -
    - - -

    - Paste your full SAS URL here to auto-fill the connection string and container fields below. -

    - @error('sas_url') -

    {{$message}}

    - @enderror -
    -
    - -
    -
    - - -

    - Will be autofilled if SAS URL is given. You can find the connection string in the Azure Portal under your Storage Account → Security + networking → Access keys. -
    Example: DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net -

    - @error('connection_string') -

    {{$message}}

    - @enderror -
    -
    - -
    -
    - - - @error('container') -

    {{$message}}

    - @enderror -
    -
    - - diff --git a/src/resources/views/store/azure.blade.php b/src/resources/views/store/azure.blade.php new file mode 100644 index 0000000..8eff677 --- /dev/null +++ b/src/resources/views/store/azure.blade.php @@ -0,0 +1,108 @@ +
    +
    +
    + + +

    + Paste your full SAS URL here to auto-fill the connection string and container fields below. +

    + @error('sas_url') +

    {{$message}}

    + @enderror +
    +
    + +
    +
    + + +

    + Will be autofilled if SAS URL is given. You can find the connection string in the Azure Portal under your Storage Account → Security + networking → Access keys. +

    + @error('connection_string') +

    {{$message}}

    + @enderror +
    +
    + +
    +
    + + + @error('container') +

    {{$message}}

    + @enderror +
    +
    +
    + +@push('scripts') + + +@endpush diff --git a/src/resources/views/update/azure-storage-blob.blade.php b/src/resources/views/update/azure-storage-blob.blade.php deleted file mode 100644 index bb237e0..0000000 --- a/src/resources/views/update/azure-storage-blob.blade.php +++ /dev/null @@ -1,90 +0,0 @@ -
    -
    - - -

    - Paste your full SAS URL here to auto-fill the connection string and container fields below. -

    - @error('sas_url') -

    {{$message}}

    - @enderror -
    -
    - -
    -
    - - -

    - Will be autofilled if SAS URL is given. You can find the connection string in the Azure Portal under your Storage Account → Security + networking → Access keys. Leave empty to keep the current value. -

    - @error('connection_string') -

    {{$message}}

    - @enderror -
    -
    - -
    -
    - - -

    Leave empty to keep the current value.

    - @error('container') -

    {{$message}}

    - @enderror -
    -
    - - diff --git a/src/resources/views/update/azure.blade.php b/src/resources/views/update/azure.blade.php new file mode 100644 index 0000000..a5381a8 --- /dev/null +++ b/src/resources/views/update/azure.blade.php @@ -0,0 +1,109 @@ +
    +
    +
    + + +

    + Paste your full SAS URL here to auto-fill the connection string and container fields below. +

    + @error('sas_url') +

    {{$message}}

    + @enderror +
    +
    + +
    +
    + + +

    + Will be autofilled if SAS URL is given. You can find the connection string in the Azure Portal under your Storage Account → Security + networking → Access keys. Leave empty to keep the current value. +

    + @error('connection_string') +

    {{$message}}

    + @enderror +
    +
    + +
    +
    + + + @error('container') +

    {{$message}}

    + @enderror +
    +
    +
    + +@push('scripts') + + +@endpush + From 34373baa43bb903af443674d5f7ed0a43e6ac8f6 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 2 Dec 2025 10:23:41 +0100 Subject: [PATCH 08/17] Fix test --- tests/UserDiskTest.php | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/UserDiskTest.php b/tests/UserDiskTest.php index 22b3de2..2523fe7 100644 --- a/tests/UserDiskTest.php +++ b/tests/UserDiskTest.php @@ -133,21 +133,17 @@ public function testGetAzureConfig() 'name' => 'account-name', 'key' => 'account-key', 'container' => 'container-name', - 'url' => 'https://account.blob.core.windows.net/container', - 'endpoint' => 'https://account.blob.core.windows.net', - 'sas_token' => '?sv=...', + 'connection_string' => 'DefaultEndpointsProtocol=https;BlobEndpoint=https://mytest.blob.core.windows.net;SharedAccessSignature=sv=2025-07-05&spr=https&st=2025-11-26T16%3A59%3A32Z&se=2026-11-27T16%3A59%3A00Z&sr=c&sp=rl&sig=123412431234%3D', ], ]); $expect = [ - 'driver' => 'azure', + 'driver' => 'azure-storage-blob', 'name' => 'account-name', 'key' => 'account-key', 'container' => 'container-name', - 'url' => 'https://account.blob.core.windows.net/container', - 'endpoint' => 'https://account.blob.core.windows.net', - 'sas_token' => '?sv=...', 'read-only' => true, + 'connection_string' => 'DefaultEndpointsProtocol=https;BlobEndpoint=https://mytest.blob.core.windows.net;SharedAccessSignature=sv=2025-07-05&spr=https&st=2025-11-26T16%3A59%3A32Z&se=2026-11-27T16%3A59%3A00Z&sr=c&sp=rl&sig=123412431234%3D', ]; $this->assertEquals($expect, $disk->getConfig()); From 7ab48305e1b169b52430700b2ca484876cc320b0 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 2 Dec 2025 10:25:09 +0100 Subject: [PATCH 09/17] Fix indentation --- composer.json | 74 +++++++++++++++++++++++++-------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/composer.json b/composer.json index 14a7735..300d096 100644 --- a/composer.json +++ b/composer.json @@ -1,39 +1,39 @@ { - "name": "biigle/user-disks", - "description": "BIIGLE module to offer private storage disks for users.", - "keywords": [ - "biigle", - "biigle-module" - ], - "license": "GPL-3.0-only", - "support": { - "source": "https://github.com/biigle/user-disks", - "issues": "https://github.com/biigle/user-disks/issues" - }, - "homepage": "https://biigle.de", - "authors": [ - { - "name": "Martin Zurowietz", - "email": "m.zurowietz@uni-bielefeld.de" - } - ], - "require": { - "biigle/laravel-elements-storage": "^2.2", - "league/flysystem-aws-s3-v3": "^3.12", - "league/flysystem-read-only": "^3.3", - "biigle/laravel-webdav": "^1.0", - "azure-oss/storage-blob-laravel": "^1.4" - }, - "autoload": { - "psr-4": { - "Biigle\\Modules\\UserDisks\\": "src" - } - }, - "extra": { - "laravel": { - "providers": [ - "Biigle\\Modules\\UserDisks\\UserDisksServiceProvider" - ] - } - } + "name": "biigle/user-disks", + "description": "BIIGLE module to offer private storage disks for users.", + "keywords": [ + "biigle", + "biigle-module" + ], + "license": "GPL-3.0-only", + "support": { + "source": "https://github.com/biigle/user-disks", + "issues": "https://github.com/biigle/user-disks/issues" + }, + "homepage": "https://biigle.de", + "authors": [ + { + "name": "Martin Zurowietz", + "email": "m.zurowietz@uni-bielefeld.de" + } + ], + "require": { + "biigle/laravel-elements-storage": "^2.2", + "league/flysystem-aws-s3-v3": "^3.12", + "league/flysystem-read-only": "^3.3", + "biigle/laravel-webdav": "^1.0", + "azure-oss/storage-blob-laravel": "^1.4" + }, + "autoload": { + "psr-4": { + "Biigle\\Modules\\UserDisks\\": "src" + } + }, + "extra": { + "laravel": { + "providers": [ + "Biigle\\Modules\\UserDisks\\UserDisksServiceProvider" + ] + } + } } From b9a3956e037a3c3f5bd9e874d5bb82e827b465d9 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 2 Dec 2025 10:44:09 +0100 Subject: [PATCH 10/17] Add tests for azure --- .../Api/UserDiskControllerTest.php | 75 +++++++++++++++++++ .../Views/UserDiskControllerTest.php | 19 +++++ 2 files changed, 94 insertions(+) diff --git a/tests/Http/Controllers/Api/UserDiskControllerTest.php b/tests/Http/Controllers/Api/UserDiskControllerTest.php index c2c89af..c5c68d8 100644 --- a/tests/Http/Controllers/Api/UserDiskControllerTest.php +++ b/tests/Http/Controllers/Api/UserDiskControllerTest.php @@ -695,6 +695,49 @@ public function testStoreAruna() $this->assertEquals($expect, $disk->options); } + public function testStoreAzure() + { + $this->beUser(); + + $this->mockController->shouldReceive('validateDiskAccess')->never(); + $this->postJson("/api/v1/user-disks", [ + 'name' => 'my disk', + 'type' => 'azure', + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['type']); + + config(['user_disks.types' => ['azure']]); + + $this->mockController->shouldReceive('validateDiskAccess')->never(); + $this->postJson("/api/v1/user-disks", [ + 'name' => 'my disk', + 'type' => 'azure', + ]) + ->assertStatus(422) + ->assertJsonValidationErrors(['connection_string', 'container']); + + $this->mockController->shouldReceive('validateDiskAccess')->once(); + $this->postJson("/api/v1/user-disks", [ + 'name' => 'my disk', + 'type' => 'azure', + 'connection_string' => 'DefaultEndpointsProtocol=https;BlobEndpoint=https://example.blob.core.windows.net;SharedAccessSignature=sv=...', + 'container' => 'example-container', + ]) + ->assertStatus(201); + + $disk = UserDisk::where('user_id', $this->user()->id)->first(); + $this->assertNotNull($disk); + $this->assertEquals('my disk', $disk->name); + $this->assertEquals('azure', $disk->type); + $this->assertNotNull($disk->expires_at); + $expect = [ + 'connection_string' => 'DefaultEndpointsProtocol=https;BlobEndpoint=https://example.blob.core.windows.net;SharedAccessSignature=sv=...', + 'container' => 'example-container', + ]; + $this->assertEquals($expect, $disk->options); + } + public function testUpdate() { $disk = UserDisk::factory()->create([ @@ -1347,6 +1390,38 @@ public function testUpdateAruna() $this->assertEquals($expect, $disk->options); } + public function testUpdateAzure() + { + config(['user_disks.types' => ['azure']]); + + $disk = UserDisk::factory()->create([ + 'type' => 'azure', + 'name' => 'abc', + 'options' => [ + 'connection_string' => 'DefaultEndpointsProtocol=https;BlobEndpoint=https://example.blob.core.windows.net;SharedAccessSignature=sv=...', + 'container' => 'example-container', + ], + ]); + + $this->be($disk->user); + $this->mockController->shouldReceive('validateDiskAccess')->once(); + $this->putJson("/api/v1/user-disks/{$disk->id}", [ + 'name' => 'cba', + 'connection_string' => 'DefaultEndpointsProtocol=https;BlobEndpoint=https://updated.blob.core.windows.net;SharedAccessSignature=sv=...', + 'container' => 'updated-container', + ]) + ->assertStatus(200); + + $disk->refresh(); + $expect = [ + 'connection_string' => 'DefaultEndpointsProtocol=https;BlobEndpoint=https://updated.blob.core.windows.net;SharedAccessSignature=sv=...', + 'container' => 'updated-container', + ]; + $this->assertEquals('azure', $disk->type); + $this->assertEquals('cba', $disk->name); + $this->assertEquals($expect, $disk->options); + } + public function testExtend() { config(['user_disks.about_to_expire_weeks' => 4]); diff --git a/tests/Http/Controllers/Views/UserDiskControllerTest.php b/tests/Http/Controllers/Views/UserDiskControllerTest.php index 8dfc738..e083f5d 100644 --- a/tests/Http/Controllers/Views/UserDiskControllerTest.php +++ b/tests/Http/Controllers/Views/UserDiskControllerTest.php @@ -53,6 +53,12 @@ public function testCreateAruna() $this->get('storage-disks/create?type=aruna&name=abc')->assertStatus(200); } + public function testCreateAzure() + { + $this->beUser(); + $this->get('storage-disks/create?type=azure&name=abc')->assertStatus(200); + } + public function testCreateInvalid() { $this->beUser(); @@ -120,4 +126,17 @@ public function testUpdateAruna() $this->be($disk->user); $this->get("storage-disks/{$disk->id}")->assertStatus(200); } + + public function testUpdateAzure() + { + $disk = UserDisk::factory()->create([ + 'type' => 'azure', + 'options' => [ + 'connection_string' => 'DefaultEndpointsProtocol=https;BlobEndpoint=https://example.blob.core.windows.net;SharedAccessSignature=sv=...', + 'container' => 'example-container', + ], + ]); + $this->be($disk->user); + $this->get("storage-disks/{$disk->id}")->assertStatus(200); + } } From 61b1909be592d13cc49cbcf423f9c5bb354a71a6 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 2 Dec 2025 10:51:49 +0100 Subject: [PATCH 11/17] Improve azure documentation --- src/resources/views/manual/types/azure.blade.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/resources/views/manual/types/azure.blade.php b/src/resources/views/manual/types/azure.blade.php index df2a8b3..efae308 100644 --- a/src/resources/views/manual/types/azure.blade.php +++ b/src/resources/views/manual/types/azure.blade.php @@ -9,15 +9,24 @@

    +
    SAS URL
    +
    +

    + If you provide a SAS URL, BIIGLE will auto-fill the connection string and container options (see below). Alternatively, you can set these options directly. +

    +
    Connection String

    The Azure Storage connection string. You can find this in the Azure Portal under your Storage Account → Access keys. -
    Example: DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=...;EndpointSuffix=core.windows.net +

    +

    + Example: +

    DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=...;EndpointSuffix=core.windows.net

    For local development with Azurite, use: -
    DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1; +

    DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;

    From 88114e9d6fc1bc30e93e7a1b39395517bbdae474 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Fri, 5 Dec 2025 16:22:46 +0100 Subject: [PATCH 12/17] Improve error handling for invalid Azure SAS --- src/resources/views/store/azure.blade.php | 10 +++++++--- src/resources/views/update/azure.blade.php | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/resources/views/store/azure.blade.php b/src/resources/views/store/azure.blade.php index 8eff677..f01280a 100644 --- a/src/resources/views/store/azure.blade.php +++ b/src/resources/views/store/azure.blade.php @@ -15,7 +15,7 @@
    - +

    Will be autofilled if SAS URL is given. You can find the connection string in the Azure Portal under your Storage Account → Security + networking → Access keys.

    @@ -28,7 +28,7 @@
    - + @error('container')

    {{$message}}

    @enderror @@ -54,7 +54,11 @@ return null; } - url = new URL(url); + try { + url = new URL(url); + } catch (e) { + return null; + } const pathParts = url.pathname.split('/').filter(p => p); let containerName = ''; diff --git a/src/resources/views/update/azure.blade.php b/src/resources/views/update/azure.blade.php index a5381a8..358a129 100644 --- a/src/resources/views/update/azure.blade.php +++ b/src/resources/views/update/azure.blade.php @@ -15,7 +15,7 @@
    - +

    Will be autofilled if SAS URL is given. You can find the connection string in the Azure Portal under your Storage Account → Security + networking → Access keys. Leave empty to keep the current value.

    @@ -28,7 +28,7 @@
    - + @error('container')

    {{$message}}

    @enderror @@ -54,7 +54,11 @@ return null; } - url = new URL(url); + try { + url = new URL(url); + } catch (e) { + return null; + } const pathParts = url.pathname.split('/').filter(p => p); let containerName = ''; From e923e80449c8e2846b7a10258e50bdf1030a8584 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 16 Dec 2025 11:46:59 +0100 Subject: [PATCH 13/17] Switch to different package for azure disk The package supports using direct public links as temporary URLs. --- composer.json | 4 +++- src/config/user_disks.php | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 300d096..ed0bf3e 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,9 @@ "league/flysystem-aws-s3-v3": "^3.12", "league/flysystem-read-only": "^3.3", "biigle/laravel-webdav": "^1.0", - "azure-oss/storage-blob-laravel": "^1.4" + }, + "suggest": { + "biigle/laravel-azure-storage": "Required if the 'azure' disk type should be enabled" }, "autoload": { "psr-4": { diff --git a/src/config/user_disks.php b/src/config/user_disks.php index 67c0336..914c613 100644 --- a/src/config/user_disks.php +++ b/src/config/user_disks.php @@ -68,6 +68,7 @@ 'driver' => 'azure-storage-blob', 'connection_string' => '', 'container' => '', + 'use_direct_public_url' => true, ], ], From dc16505f8fb089bece582626ea65b009673ea709 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 16 Dec 2025 11:58:16 +0100 Subject: [PATCH 14/17] Update help text and manual --- src/resources/views/manual/types/azure.blade.php | 9 ++++----- src/resources/views/store/azure.blade.php | 3 --- src/resources/views/update/azure.blade.php | 2 +- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/resources/views/manual/types/azure.blade.php b/src/resources/views/manual/types/azure.blade.php index efae308..b29c19b 100644 --- a/src/resources/views/manual/types/azure.blade.php +++ b/src/resources/views/manual/types/azure.blade.php @@ -9,7 +9,7 @@

    -
    SAS URL
    +
    SAS URL (optional)

    If you provide a SAS URL, BIIGLE will auto-fill the connection string and container options (see below). Alternatively, you can set these options directly. @@ -21,12 +21,11 @@ The Azure Storage connection string. You can find this in the Azure Portal under your Storage Account → Access keys.

    - Example: -

    DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=...;EndpointSuffix=core.windows.net
    + BIIGLE uses direct URLs to load files more efficiently in the browser. If the account name and key are provided in the connection string, BIIGLE can generate temporary URLs for this purpose. Otherwise it can use a signature from a SAS as direct URL. However, you will have to manually update the SAS in the storage disk configuration whenever it expires.

    - For local development with Azurite, use: -

    DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;
    + Example: +
    DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=...;EndpointSuffix=core.windows.net

    diff --git a/src/resources/views/store/azure.blade.php b/src/resources/views/store/azure.blade.php index f01280a..91384e9 100644 --- a/src/resources/views/store/azure.blade.php +++ b/src/resources/views/store/azure.blade.php @@ -16,9 +16,6 @@
    -

    - Will be autofilled if SAS URL is given. You can find the connection string in the Azure Portal under your Storage Account → Security + networking → Access keys. -

    @error('connection_string')

    {{$message}}

    @enderror diff --git a/src/resources/views/update/azure.blade.php b/src/resources/views/update/azure.blade.php index 358a129..f0937f5 100644 --- a/src/resources/views/update/azure.blade.php +++ b/src/resources/views/update/azure.blade.php @@ -17,7 +17,7 @@

    - Will be autofilled if SAS URL is given. You can find the connection string in the Azure Portal under your Storage Account → Security + networking → Access keys. Leave empty to keep the current value. + Leave empty to keep the current value.

    @error('connection_string')

    {{$message}}

    From f2527578ec71f81ac57be8b491bfc9c86f399822 Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 16 Dec 2025 11:59:16 +0100 Subject: [PATCH 15/17] Fix composer.json --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index ed0bf3e..8daa6f3 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "biigle/laravel-elements-storage": "^2.2", "league/flysystem-aws-s3-v3": "^3.12", "league/flysystem-read-only": "^3.3", - "biigle/laravel-webdav": "^1.0", + "biigle/laravel-webdav": "^1.0" }, "suggest": { "biigle/laravel-azure-storage": "Required if the 'azure' disk type should be enabled" From 23bca66320414548777899e80050f7a3ee3f715e Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 16 Dec 2025 12:02:31 +0100 Subject: [PATCH 16/17] Fix test --- tests/UserDiskTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/UserDiskTest.php b/tests/UserDiskTest.php index 2523fe7..2310ac8 100644 --- a/tests/UserDiskTest.php +++ b/tests/UserDiskTest.php @@ -143,6 +143,7 @@ public function testGetAzureConfig() 'key' => 'account-key', 'container' => 'container-name', 'read-only' => true, + 'use_direct_public_url' => true, 'connection_string' => 'DefaultEndpointsProtocol=https;BlobEndpoint=https://mytest.blob.core.windows.net;SharedAccessSignature=sv=2025-07-05&spr=https&st=2025-11-26T16%3A59%3A32Z&se=2026-11-27T16%3A59%3A00Z&sr=c&sp=rl&sig=123412431234%3D', ]; From 4ebc47d090fcd23edb2237ddd7111b595d641cbb Mon Sep 17 00:00:00 2001 From: Martin Zurowietz Date: Tue, 16 Dec 2025 12:07:33 +0100 Subject: [PATCH 17/17] Update configuration instructions for optional packages --- README.md | 21 +++++++++++++++++++++ composer.json | 6 +++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c493bc0..f80ba8a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,27 @@ This is a BIIGLE module that offers private storage disks for users. This module supports `s3`, `webdav`, `elements` and `aruna` storage disks but by default only S3 is enabled. Configure the enabled storage disk types as a comma-separated list with the `USER_DISKS_TYPES` environment variable (e.g. `s3,webdav`). +### Required Configuration by Disk Type + +Different storage disk types require additional packages to be installed: + +- **S3**: No additional packages required (included by default) +- **Aruna**: No additional packages required (included by default but disabled) +- **Elements**: Requires `biigle/laravel-elements-storage` + ```bash + composer require biigle/laravel-elements-storage + ``` +- **WebDAV**: Requires `biigle/laravel-webdav` + ```bash + composer require biigle/laravel-webdav + ``` +- **Azure**: Requires `biigle/laravel-azure-storage` + ```bash + composer require biigle/laravel-azure-storage + ``` + +Install only the packages for the disk types you plan to enable. + ## Installation 1. Run `composer require biigle/user-disks`. diff --git a/composer.json b/composer.json index 8daa6f3..820ad25 100644 --- a/composer.json +++ b/composer.json @@ -18,12 +18,12 @@ } ], "require": { - "biigle/laravel-elements-storage": "^2.2", "league/flysystem-aws-s3-v3": "^3.12", - "league/flysystem-read-only": "^3.3", - "biigle/laravel-webdav": "^1.0" + "league/flysystem-read-only": "^3.3" }, "suggest": { + "biigle/laravel-elements-storage": "Required if the 'elements' disk type should be enabled", + "biigle/laravel-webdav": "Required if the 'webdav' disk type should be enabled", "biigle/laravel-azure-storage": "Required if the 'azure' disk type should be enabled" }, "autoload": {