From 94a300622813d425d5146267bdfc079a4afcf0ed Mon Sep 17 00:00:00 2001 From: Mwikala Kangwa <39342367+mwikala@users.noreply.github.com> Date: Sat, 18 Jul 2026 23:44:50 +0000 Subject: [PATCH] fix: support Linode Object Storage ACL limitations --- CHANGELOG.md | 9 + README.md | 81 +++++--- composer.json | 13 +- src/Fs.php | 178 ++++++++-------- src/LinodeS3Bundle.php | 6 +- src/LinodeS3V3Adapter.php | 196 ++++++++++++++++++ src/Plugin.php | 8 +- ...900_update_linode_volume_to_filesystem.php | 9 +- src/templates/fsSettings.html | 127 ++++++++---- 9 files changed, 457 insertions(+), 170 deletions(-) create mode 100644 src/LinodeS3V3Adapter.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 76bbf8f..964f9c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 2.1.0 - 2026-07-18 +- Added Craft CMS 5 compatibility updates while retaining Craft CMS 4 support. +- Updated dependency constraints to stable Craft CMS 4/5 and Craft Flysystem releases. +- Updated the filesystem implementation to use strict types, typed properties, and the current AWS SDK Guzzle handler when available. +- Added a Linode-aware S3 adapter that omits unsupported object ACL operations while preserving multipart uploads and MIME type detection. +- Added the missing Content-Disposition setting field to the filesystem settings UI. +- Improved Linode-specific setup guidance and endpoint examples in the control panel and README. +- Fixed the Craft 3 volume-to-filesystem migration schema-version lookup to use the plugin handle. + ## 2.0.1 - 2022-08-10 - Fixed bug where a call was made to Fs::getSubFolder (Method was removed in 2.0.0) diff --git a/README.md b/README.md index 4df5a4f..0dbe69f 100644 --- a/README.md +++ b/README.md @@ -2,65 +2,96 @@

Linode Object Storage for Craft CMS

-This plugin provides a [Linode Object Storage](https://www.linode.com/products/object-storage/) integration for [Craft CMS](https://craftcms.com/). +This plugin provides a [Linode Object Storage](https://www.linode.com/products/object-storage/) filesystem for [Craft CMS](https://craftcms.com/). ## Requirements -This plugin requires Craft CMS 4 or later. +- Craft CMS 4.0+ or 5.0+ +- PHP 8.0.2+ +- PHP 8.2+ when running Craft CMS 5 ## Installation -You can install this plugin from the Plugin Store or with Composer. +Install the plugin from the Plugin Store, or with Composer: -#### From the Plugin Store +```bash +cd /path/to/my-craft-project +composer require mwikala/linode-s3 +php craft plugin/install linode-s3 +``` -Go to the Plugin Store in your project’s Control Panel and search for “Linode Object Storage”. Then click on the “Install” button in its modal window. +The Composer package name is `mwikala/linode-s3` and the Craft plugin handle is `linode-s3`. -#### With Composer +## Setup -Open your terminal and run the following commands: +1. In Linode Cloud Manager, create an Object Storage bucket and an access key with access to that bucket. +2. In Craft, go to Settings → Filesystems and create a new filesystem. +3. Set **Filesystem Type** to **Linode Object Storage**. +4. Enter your bucket details, or use environment variables such as `$LINODE_S3_BUCKET`. +5. If files should be publicly accessible, enable **Files in this filesystem have public URLs** and set **Base URL** to the bucket URL or CDN/custom-domain URL. -```bash -# go to the project directory -cd /path/to/my-project.test +Linode bucket URLs usually look like this: -# tell Composer to load the plugin -composer require mwikala/linode-s3 +```text +https://[bucket-name].[region].linodeobjects.com +``` -# tell Craft to install the plugin -./craft plugin/install linode-s3 +The plugin’s **API Endpoint** setting should be the same URL without the bucket name: + +```text +https://[region].linodeobjects.com ``` -## Setup +For example, a bucket URL of `https://assets.eu-central-1.linodeobjects.com` would use: + +| Setting | Value | +| --- | --- | +| API Endpoint | `https://eu-central-1.linodeobjects.com` | +| Region | `eu-central-1` | +| Bucket | `assets` | +| Base URL | `https://assets.eu-central-1.linodeobjects.com` | -To create a new asset file system for your Amazon S3 bucket, go to Settings → Filesystems, create a new filesystem, and set the Filesystem Type setting to “Linode S3”. +If you set a **Subfolder**, don’t append it to the **Base URL**. Craft adds the subfolder automatically when generating asset URLs. ## Environment Variables -To allow you to setup different Buckets for different environments, you can set these handy environment variables in your `.env` and `.env.example` (so you don't forget them after pushing to source control): +You can keep credentials and environment-specific bucket details in your project’s `.env` file: ```env -# The Linode Object Storage Access Key with read/write access to Buckets +# Linode Object Storage access key with read/write access to the bucket LINODE_S3_ACCESS_KEY= -# The Linode Object Storage Access Secret +# Linode Object Storage secret key LINODE_S3_SECRET= -# THE URL endpoint for your Buckets +# API endpoint, e.g. https://eu-central-1.linodeobjects.com LINODE_S3_ENDPOINT= -# The region your Object Storage bucket is in +# Region, e.g. eu-central-1 LINODE_S3_REGION= -# The name of your bucket +# Bucket name, e.g. assets LINODE_S3_BUCKET= -# The URL of the bucket +# Public bucket, CDN, or custom-domain URL LINODE_S3_BUCKET_URL= ``` +Then use these values in the filesystem settings: + +| Setting | Value | +| --- | --- | +| API Endpoint | `$LINODE_S3_ENDPOINT` | +| Region | `$LINODE_S3_REGION` | +| Bucket | `$LINODE_S3_BUCKET` | +| Access Key ID | `$LINODE_S3_ACCESS_KEY` | +| Secret Access Key | `$LINODE_S3_SECRET` | +| Base URL | `$LINODE_S3_BUCKET_URL` | + +Do not commit real access keys or secrets to source control. + ## License & Support -This plugin is released under the [MIT license](./LICENSE.md), meaning you can do whatever you please with it. +This plugin is released under the [MIT license](./LICENSE.md). -> If you experience any issues with the plugin then open an issue here and I'll try get it fixed/answered whenever I have some free time +If you experience any issues with the plugin, please open an issue on GitHub and I’ll try to get it fixed or answered when I have some free time. diff --git a/composer.json b/composer.json index c6e56c5..036de1d 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,6 @@ "name": "mwikala/linode-s3", "description": "This plugin provides Linode Object Storage integration for Craft CMS", "type": "craft-plugin", - "version": "2.0.2", "keywords": [ "aws", "assets", @@ -26,13 +25,13 @@ "email": "hello@mwikala.co.uk", "issues": "https://github.com/mwikala/linode-s3/issues?state=open", "source": "https://github.com/mwikala/linode-s3", - "docs": "https://github.com/mwikala/linode-s3/blob/master/README.md", - "rss": "https://github.com/mwikala/linode-s3/commits/master.atom" + "docs": "https://github.com/mwikala/linode-s3/blob/main/README.md", + "rss": "https://github.com/mwikala/linode-s3/commits/main.atom" }, "require": { - "php": "^8.0", - "craftcms/cms": "^4.0.0-beta.4 | ^5.0.0", - "craftcms/flysystem": "^1.0.0-beta.2 | ^2.0.0", + "php": "^8.0.2", + "craftcms/cms": "^4.0.0|^5.0.0", + "craftcms/flysystem": "^1.0.2|^2.0.1", "league/flysystem-aws-s3-v3": "^3.0.0" }, "autoload": { @@ -44,7 +43,7 @@ "name": "Linode Object Storage", "handle": "linode-s3", "schemaVersion": "2.0.0", - "documentationUrl": "https://github.com/mwikala/linode-s3/blob/master/README.md" + "documentationUrl": "https://github.com/mwikala/linode-s3/blob/main/README.md" }, "config": { "allow-plugins": { diff --git a/src/Fs.php b/src/Fs.php index 850299e..f760e37 100644 --- a/src/Fs.php +++ b/src/Fs.php @@ -1,22 +1,21 @@ EnvAttributeParserBehavior::class, 'attributes' => [ - 'subfolder', 'keyId', 'secret', 'region', 'bucket', 'endpoint' + 'subfolder', + 'keyId', + 'secret', + 'endpoint', + 'bucket', + 'region', ], ]; @@ -141,9 +145,9 @@ public function getSettingsHtml(): ?string 'contentDispositionOptions' => [ '' => 'none', 'inline' => 'inline', - 'attachment' => 'attachment' + 'attachment' => 'attachment', ], - 'contentDisposition' => $this->contentDisposition + 'contentDisposition' => $this->contentDisposition, ]); } @@ -155,6 +159,7 @@ public function getRootUrl(): ?string if (($rootUrl = parent::getRootUrl()) !== false && $this->_subfolder()) { $rootUrl .= rtrim($this->_subfolder(), '/') . '/'; } + return $rootUrl; } @@ -163,36 +168,30 @@ public function getRootUrl(): ?string /** * @inheritdoc - * @return AwsS3Adapter + * @return LinodeS3V3Adapter */ protected function createAdapter(): FilesystemAdapter { - $client = static::client($this->_getConfigArray(), $this->_getCredentials()); - - return new AwsS3V3Adapter($client, App::parseEnv($this->bucket), $this->_subfolder(), null, null, [], false); + $client = static::client($this->_getConfigArray()); + + return new LinodeS3V3Adapter( + $client, + App::parseEnv($this->bucket), + $this->_subfolder(), + null, + null, + [], + false, + ); } /** - * Get the Amazon S3 Client + * Get the S3-compatible client. * - * @param $config - * @return S3Client + * @param array $config client config */ - protected static function client(array $config = [], array $credentials = []): S3Client + protected static function client(array $config = []): S3Client { - if (!empty($config['credentials']) && $config['credentials'] instanceof Credentials) { - $config['generateNewConfig'] = static function() use ($credentials) { - $args = [ - $credentials['keyId'], - $credentials['secret'], - $credentials['region'], - true, - ]; - - return call_user_func_array(self::class . '::buildConfigArray', $args); - }; - } - return new S3Client($config); } @@ -205,21 +204,35 @@ protected function addFileMetadataToConfig(array $config): array $expires = new DateTime(); $now = new DateTime(); $expires->modify('+' . $this->expires); - $diff = (int) $expires->format('U') - (int) $now->format('U'); - $config['CacheControl'] = 'max-age=' . $diff . ', must-revalidate'; + $diff = (int)$expires->format('U') - (int)$now->format('U'); + $config['CacheControl'] = 'max-age=' . $diff; + } + + if (!empty($this->contentDisposition)) { $config['ContentDisposition'] = $this->contentDisposition; } - return parent::addFileMetadataToConfig($config); + // Linode Object Storage doesn't support S3 object ACL headers like x-amz-acl. + // Craft's base Flysystem implementation adds visibility metadata, which the + // AWS S3 adapter translates into those ACL headers, so return our metadata + // directly and leave object access control to the bucket's Linode settings. + return $config; + } + + /** + * @inheritdoc + */ + protected function invalidateCdnPath(string $path): bool + { + // Not implemented; Linode Object Storage doesn't provide a first-party CDN purge API here. + return true; } // Private Methods // ========================================================================= /** - * Returns the parsed subfolder path - * - * @return string + * Returns the parsed subfolder path. */ private function _subfolder(): string { @@ -231,27 +244,24 @@ private function _subfolder(): string } /** - * Get the config array for AWS client - * - * @return array + * Get the config array for AWS clients. */ - private function _getConfigArray() + private function _getConfigArray(): array { $credentials = $this->_getCredentials(); - return self::_buildConfigArray($credentials['keyId'], $credentials['secret'], $credentials['region'], $credentials['endpoint']); + return self::_buildConfigArray( + $credentials['keyId'], + $credentials['secret'], + $credentials['region'], + $credentials['endpoint'], + ); } /** - * Built the config array - * - * @param $keyId - * @param $secret - * @param $region - * @param $endpoint - * @return array + * Build the config array. */ - private static function _buildConfigArray($keyId = null, $secret = null, $region = null, $endpoint = null): array + private static function _buildConfigArray(?string $keyId = null, ?string $secret = null, ?string $region = null, ?string $endpoint = null): array { $config = [ 'region' => $region, @@ -264,15 +274,15 @@ private static function _buildConfigArray($keyId = null, $secret = null, $region ]; $client = Craft::createGuzzleClient(); - $config['http_handler'] = new GuzzleHandler($client); + $config['http_handler'] = class_exists('Aws\\Handler\\Guzzle\\GuzzleHandler') + ? new \Aws\Handler\Guzzle\GuzzleHandler($client) + : new \Aws\Handler\GuzzleV6\GuzzleHandler($client); return $config; } /** - * Return the credentials as an array - * - * @return array + * Return the credentials as an array. */ private function _getCredentials(): array { @@ -283,10 +293,4 @@ private function _getCredentials(): array 'endpoint' => App::parseEnv($this->endpoint), ]; } - - /** @inheritdoc */ - protected function invalidateCdnPath(string $path): bool - { - return true; - } } diff --git a/src/LinodeS3Bundle.php b/src/LinodeS3Bundle.php index 20530ec..2cc3a64 100644 --- a/src/LinodeS3Bundle.php +++ b/src/LinodeS3Bundle.php @@ -1,12 +1,14 @@ js = [ - 'js/editFilesystem.js' + 'js/editFilesystem.js', ]; parent::init(); diff --git a/src/LinodeS3V3Adapter.php b/src/LinodeS3V3Adapter.php new file mode 100644 index 0000000..ad25f76 --- /dev/null +++ b/src/LinodeS3V3Adapter.php @@ -0,0 +1,196 @@ +mimeTypeDetector = $mimeTypeDetector ?? new FinfoMimeTypeDetector(); + + parent::__construct( + $linodeClient, + $linodeBucket, + $linodePrefix, + $visibility, + $this->mimeTypeDetector, + $linodeOptions, + $streamReads, + ); + } + + private MimeTypeDetector $mimeTypeDetector; + + public function write(string $path, string $contents, Config $config): void + { + $this->upload($path, $contents, $config); + } + + public function writeStream(string $path, $contents, Config $config): void + { + $this->upload($path, $contents, $config); + } + + public function createDirectory(string $path, Config $config): void + { + $this->upload(rtrim($path, '/') . '/', '', $config); + } + + public function copy(string $source, string $destination, Config $config): void + { + if ($source === $destination) { + return; + } + + $arguments = array_merge($this->copyOptionsFromConfig($config), [ + 'Bucket' => $this->linodeBucket, + 'Key' => $this->prefixPath($destination), + 'CopySource' => sprintf('%s/%s', $this->linodeBucket, $this->prefixPath($source)), + 'MetadataDirective' => $config->get('MetadataDirective', 'COPY'), + ]); + + try { + $this->linodeClient->copyObject($arguments); + } catch (Throwable $exception) { + throw UnableToCopyFile::fromLocationTo($source, $destination, $exception); + } + } + + public function move(string $source, string $destination, Config $config): void + { + if ($source === $destination) { + return; + } + + try { + $this->copy($source, $destination, $config); + $this->delete($source); + } catch (Throwable $exception) { + throw UnableToMoveFile::fromLocationTo($source, $destination, $exception); + } + } + + public function setVisibility(string $path, string $visibility): void + { + throw UnableToSetVisibility::atLocation($path, 'Linode Object Storage does not support object ACLs.'); + } + + public function visibility(string $path): FileAttributes + { + return new FileAttributes($path, null, Visibility::PRIVATE); + } + + private function upload(string $path, $body, Config $config): void + { + $key = $this->prefixPath($path); + $options = $this->multipartOptionsFromConfig($config); + + if (!isset($options['params']['ContentType']) && ($mimeType = $this->mimeTypeDetector->detectMimeType($key, $body))) { + $options['params']['ContentType'] = $mimeType; + } + + try { + $this->linodeClient->upload($this->linodeBucket, $key, $body, null, $options); + } catch (Throwable $exception) { + throw UnableToWriteFile::atLocation($path, $exception->getMessage(), $exception); + } + } + + private function multipartOptionsFromConfig(Config $config): array + { + $config = $config->withDefaults($this->linodeOptions); + $options = ['params' => []]; + + if ($mimetype = $config->get('mimetype')) { + $options['params']['ContentType'] = $mimetype; + } + + foreach (self::FORWARDED_OPTIONS as $option) { + $value = $config->get($option, '__NOT_SET__'); + if ($value !== '__NOT_SET__') { + $options['params'][$option] = $value; + } + } + + foreach (AwsS3V3Adapter::MUP_AVAILABLE_OPTIONS as $option) { + $value = $config->get($option, '__NOT_SET__'); + if ($value !== '__NOT_SET__') { + $options[$option] = $value; + } + } + + return $options; + } + + private function copyOptionsFromConfig(Config $config): array + { + $config = $config->withDefaults($this->linodeOptions); + $options = []; + + foreach (self::FORWARDED_OPTIONS as $option) { + $value = $config->get($option, '__NOT_SET__'); + if ($value !== '__NOT_SET__') { + $options[$option] = $value; + } + } + + return $options; + } + + private function prefixPath(string $path): string + { + return trim($this->linodePrefix . ltrim($path, '/'), '/'); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index b0fd8d2..361551f 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -1,21 +1,21 @@ * @since 3.4 */ class Plugin extends \craft\base\Plugin { - /** * @inheritdoc */ @@ -23,7 +23,7 @@ public function init(): void { parent::init(); - Event::on(FsService::class, FsService::EVENT_REGISTER_FILESYSTEM_TYPES, static function (RegisterComponentTypesEvent $event) { + Event::on(FsService::class, FsService::EVENT_REGISTER_FILESYSTEM_TYPES, static function(RegisterComponentTypesEvent $event): void { $event->types[] = Fs::class; }); } diff --git a/src/migrations/m220725_123900_update_linode_volume_to_filesystem.php b/src/migrations/m220725_123900_update_linode_volume_to_filesystem.php index 2dcac6a..c50a26f 100644 --- a/src/migrations/m220725_123900_update_linode_volume_to_filesystem.php +++ b/src/migrations/m220725_123900_update_linode_volume_to_filesystem.php @@ -1,5 +1,7 @@ getProjectConfig(); - $schemaVersion = $projectConfig->get('plugins.linodes3.schemaVersion', true); - if (version_compare($schemaVersion, '2.0', '>=')) { + $schemaVersion = $projectConfig->get('plugins.linode-s3.schemaVersion', true) + ?? $projectConfig->get('plugins.linodes3.schemaVersion', true); + if ($schemaVersion !== null && version_compare($schemaVersion, '2.0.0', '>=')) { return true; } $fsConfigs = $projectConfig->get(ProjectConfig::PATH_FS) ?? []; foreach ($fsConfigs as $uid => $config) { - if ($config['type'] === 'mwikala\linodes3\Volume') { + if (($config['type'] ?? null) === 'mwikala\\linodes3\\Volume') { $config['type'] = Fs::class; $projectConfig->set(sprintf('%s.%s', ProjectConfig::PATH_FS, $uid), $config); } diff --git a/src/templates/fsSettings.html b/src/templates/fsSettings.html index aec252d..ea20d50 100644 --- a/src/templates/fsSettings.html +++ b/src/templates/fsSettings.html @@ -1,39 +1,38 @@ {% import "_includes/forms" as forms %} -{{ forms.autosuggestField({ - label: "Access Key ID"|t('linode-s3'), - id: 'keyId', - class: 'ltr', - name: 'keyId', - suggestEnvVars: true, - suggestAliases: true, - value: (fs is defined ? fs.keyId : null), - errors: (fs is defined ? fs.getErrors('keyId') : null), - required: true, -}) }} +
+
+

+ In Linode Cloud Manager, open your Object Storage bucket and copy the bucket URL.
+ The URL will look something like this:

-{{ forms.autosuggestField({ - label: "Access Secret"|t('linode-s3'), - id: 'secret', - class: 'ltr', - name: 'secret', - suggestEnvVars: true, - suggestAliases: true, - value: (fs is defined ? fs.secret : null), - errors: (fs is defined ? fs.getErrors('secret') : null), - required: true, -}) }} + https://[bucket-name].[region].linodeobjects.com

+ + The API Endpoint is this URL without the bucket name, like this:

+ + https://[region].linodeobjects.com

+ + The “Region” and “Bucket” settings below are the corresponding [region] and [bucket-name] values.

+ + To set the “Base URL” above, enable the “Files in this filesystem have public URLs” lightswitch. + The value should be the whole bucket URL. Do not add the subfolder from the “Subfolder” setting below if you’ve configured one. +

+
+
+ +
{{ forms.autosuggestField({ - label: "Endpoint"|t('linode-s3'), + label: "API Endpoint"|t('linode-s3'), id: 'endpoint', class: 'ltr', name: 'endpoint', suggestEnvVars: true, suggestAliases: true, - value: (fs is defined ? fs.endpoint : null), - errors: (fs is defined ? fs.getErrors('endpoint') : null), + value: fs.endpoint, + errors: fs.getErrors('endpoint'), required: true, + placeholder: "https://eu-central-1.linodeobjects.com"|t('linode-s3') }) }} {{ forms.autosuggestField({ @@ -43,10 +42,10 @@ name: 'region', suggestEnvVars: true, suggestAliases: true, - value: (fs is defined ? fs.region : null), - errors: (fs is defined ? fs.getErrors('region') : null), + value: fs.region, + errors: fs.getErrors('region'), required: true, - placeholder: "ams3"|t('linode-s3') + placeholder: "eu-central-1"|t('linode-s3') }) }} {{ forms.autosuggestField({ @@ -56,48 +55,83 @@ name: 'bucket', suggestEnvVars: true, suggestAliases: true, - value: (fs is defined ? fs.bucket : null), - errors: (fs is defined ? fs.getErrors('bucket') : null), + value: fs.bucket, + errors: fs.getErrors('bucket'), required: true, placeholder: "your-bucket-name"|t('linode-s3') }) }} {{ forms.autosuggestField({ label: "Subfolder"|t('linode-s3'), - instructions: "If you want to use a bucket's subfolder as a File System, specify the path to use here."|t('linode-s3'), + instructions: "If you want to use a bucket’s subfolder as a filesystem, specify the path to use here."|t('linode-s3'), id: 'subfolder', class: 'ltr', name: 'subfolder', suggestEnvVars: true, suggestAliases: true, - value: (fs is defined ? fs.subfolder : null), - errors: (fs is defined ? fs.getErrors('subfolder') : null), - required: false, + value: fs.subfolder, + errors: fs.getErrors('subfolder'), placeholder: "path/to/subfolder"|t('linode-s3') }) }}
+
+
+

+ Access Key ID and Secret Access Key can be created from Linode Cloud Manager under Object Storage → Access Keys. +

+
+
+ +
+ +{{ forms.autosuggestField({ + label: "Access Key ID"|t('linode-s3'), + id: 'keyId', + class: 'ltr', + name: 'keyId', + suggestEnvVars: true, + suggestAliases: true, + value: fs.keyId, + errors: fs.getErrors('keyId'), + required: true, +}) }} + +{{ forms.autosuggestField({ + label: "Secret Access Key"|t('linode-s3'), + id: 'secret', + class: 'ltr', + name: 'secret', + suggestEnvVars: true, + suggestAliases: true, + value: fs.secret, + errors: fs.getErrors('secret'), + required: true, +}) }} + +
+ {% set cacheInput %} -{% set expires = (fs.expires|length > 0 ? fs.expires|split(' ') : ['', ''])%} + {% set expires = (fs.expires|length > 0 ? fs.expires|split(' ') : ['', ''])%} -
-
- {{ forms.text({ +
+
+ {{ forms.text({ id: 'expiresAmount', value: expires[0], size: 2, class: 'ls3-expires-amount' }) }} -
- {{ forms.select({ +
+ {{ forms.select({ id: 'expiresPeriod', options: periods, value: expires[1], class: 'ls3-expires-period' }) }} -
-{{ forms.hidden({ +
+ {{ forms.hidden({ name: "expires", value: fs.expires, class: "expires-combined" @@ -110,5 +144,14 @@ id: 'cacheDuration', }, cacheInput) }} +{{ forms.selectField({ + label: "Content Disposition"|t, + instructions: "The Content-Disposition header indicates whether content should be displayed inline in the browser or downloaded and saved locally."|t, + id: 'contentDisposition', + name: 'contentDisposition', + options: contentDispositionOptions, + value: contentDisposition, + class: 'ls3-content-disposition' +}) }} {% do view.registerAssetBundle("mwikala\\linodes3\\LinodeS3Bundle") %}