Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions src/AzureBlobStorageAdapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<?php

namespace Biigle\Modules\UserDisks;

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 League\Flysystem\FilesystemAdapter;

class AzureBlobStorageAdapter implements FilesystemAdapter
{
private BaseAdapter $adapter;
private BlobContainerClient $client;
private string $prefix;

public function __construct(BlobContainerClient $client, string $prefix = '')
{
$this->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));
}
}
37 changes: 37 additions & 0 deletions src/AzureFilesystemAdapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace Biigle\Modules\UserDisks;

use Illuminate\Filesystem\FilesystemAdapter;

class AzureFilesystemAdapter extends FilesystemAdapter
{
/**
* Get the URL for the file at the given path.
*
* @param string $path
* @return string
*/
public function url($path)
{
if (isset($this->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;
}
}
2 changes: 1 addition & 1 deletion src/Http/Controllers/Api/UserDiskController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()]);
}
}

Expand Down
1 change: 1 addition & 0 deletions src/UserDisk.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class UserDisk extends Model
'webdav' => 'WebDAV',
'elements' => 'Elements',
'aruna' => 'Aruna',
'azure' => 'Azure Blob Storage',
];

/**
Expand Down
66 changes: 66 additions & 0 deletions src/UserDisksServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -136,4 +137,69 @@ 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
);
});
}
}
28 changes: 28 additions & 0 deletions src/config/user_disks.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@
'secret' => '',
'endpoint' => '',
],

'azure' => [
'driver' => 'azure',
'name' => '',
'key' => '',
'container' => '',
'url' => '',
'endpoint' => '',
'sas_token' => '',
],
],

/*
Expand Down Expand Up @@ -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',
],
],

/*
Expand Down Expand Up @@ -128,6 +147,15 @@
'key' => 'filled',
'secret' => 'filled',
],

'azure' => [
'name' => 'filled',
'key' => 'filled',
'container' => 'filled',
'url' => 'filled|url',
'endpoint' => 'filled|url',
'sas_token' => 'filled',
],
],

/*
Expand Down
9 changes: 9 additions & 0 deletions src/resources/views/manual/tutorials/about.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@
<a href="#aruna">Aruna</a>
</li>
@endif
@if(in_array('azure', config('user_disks.types')))
<li>
<a href="#azure">Azure Blob Storage</a>
</li>
@endif
@if(empty(config('user_disks.types')))
<li class="text-muted">
No types are available. Please ask your administrator for help.
Expand All @@ -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
</div>
@endsection
Loading