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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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;