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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
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 @@
+
+
+
+
+
+
+
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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
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 @@
-
-
-
-
-
-
-
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 @@
+
+
+@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 @@
-
-
-
-
-
-
-
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 @@
+
+
+@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;