Skip to content

Commit df6098e

Browse files
authored
feat(assets): Adds Asset CDN Controller (#89)
1 parent e4cb049 commit df6098e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+4721
-1
lines changed

README.md

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,158 @@ It works out of the box with:
626626
- A default TipTap editor configuration
627627
- Automatic rendering of registered Storyblok blocks using the `Storyblok\Bundle\Block\BlockRegistry`
628628

629+
630+
## CDN Asset Handling
631+
632+
> [!WARNING]
633+
> This feature only supports **public Storyblok assets**. Private assets are not supported.
634+
635+
The bundle provides a CDN feature that allows you to serve Storyblok assets through your own domain. This is useful for:
636+
- Serving assets from your own CDN
637+
- Applying custom caching strategies
638+
- Avoiding mixed content issues
639+
- Better control over asset delivery
640+
641+
### Configuration
642+
643+
First, enable the CDN route in your application:
644+
645+
```yaml
646+
# config/routes/storyblok.yaml
647+
storyblok_cdn:
648+
resource: '@StoryblokBundle/config/routes/cdn.php'
649+
```
650+
651+
The CDN feature is **enabled by default** with filesystem storage at `%kernel.project_dir%/var/cdn`. It only supports public Storyblok assets.
652+
653+
```yaml
654+
# config/packages/storyblok.yaml
655+
storyblok:
656+
# ...
657+
658+
# CDN is enabled by default with these settings:
659+
# cdn:
660+
# enabled: true
661+
# storage:
662+
# type: filesystem
663+
# path: '%kernel.project_dir%/var/cdn'
664+
665+
# Custom configuration example:
666+
cdn:
667+
storage:
668+
type: filesystem
669+
path: '%kernel.project_dir%/var/cdn'
670+
cache:
671+
public: true
672+
max_age: 31536000 # 1 year
673+
smax_age: 31536000
674+
```
675+
676+
#### Disabling CDN
677+
678+
To disable the CDN feature and remove all related services:
679+
680+
```yaml
681+
storyblok:
682+
cdn: false
683+
```
684+
685+
### Usage in Twig
686+
687+
#### Generating CDN URLs
688+
689+
Use the `cdn_url` function to generate a URL that serves the asset through your CDN:
690+
691+
```twig
692+
{# From an Asset #}
693+
<img src="{{ cdn_url(asset) }}" alt="My image">
694+
695+
{# From an Image (with transformations) #}
696+
{% set image = asset|storyblok_image(800, 600) %}
697+
<img src="{{ cdn_url(image) }}" alt="Resized image">
698+
699+
{# Combine filter and function #}
700+
<img src="{{ cdn_url(asset|storyblok_image(400, 300)) }}" alt="Thumbnail">
701+
```
702+
703+
The `cdn_url` function accepts an optional second argument to specify the URL reference type. See Symfony's [UrlGeneratorInterface](https://symfony.com/doc/current/routing.html#generating-urls) for available options (`ABSOLUTE_URL`, `ABSOLUTE_PATH`, `RELATIVE_PATH`, `NETWORK_PATH`).
704+
705+
#### Supported Formats
706+
707+
The CDN supports all file formats served by Storyblok, including images (JPG, PNG, WebP, GIF, SVG, AVIF), documents (PDF), and other assets.
708+
709+
**Example URLs:**
710+
711+
| Asset Type | Storyblok URL | CDN URL |
712+
|------------|---------------|---------|
713+
| Original image | `https://a.storyblok.com/f/12345/1920x1080/abc123/photo.jpg` | `https://example.com/f/a1b2c3d4e5f6g7h8/1920x1080-photo.jpg` |
714+
| Resized image | `https://a.storyblok.com/f/12345/1920x1080/abc123/photo.jpg/m/800x600` | `https://example.com/f/b2c3d4e5f6g7h8i9/800x600-photo.jpg` |
715+
| PDF document | `https://a.storyblok.com/f/12345/document.pdf` | `https://example.com/f/c3d4e5f6g7h8i9j0/document.pdf` |
716+
717+
> [!WARNING]
718+
> Private assets are not supported yet. Only public Storyblok assets can be served through the CDN.
719+
720+
### Cleanup Command
721+
722+
The bundle provides a console command to clean up cached CDN files:
723+
724+
```bash
725+
# Delete all cached files
726+
php bin/console storyblok:cdn:cleanup
727+
728+
# Preview what would be deleted (dry-run)
729+
php bin/console storyblok:cdn:cleanup --dry-run
730+
731+
# Delete only expired files
732+
php bin/console storyblok:cdn:cleanup --expired
733+
```
734+
735+
> [!TIP]
736+
> Configure this command as a cronjob to automatically clean up expired files. For example, to run daily at midnight:
737+
> ```bash
738+
> 0 0 * * * php bin/console storyblok:cdn:cleanup --expired
739+
> ```
740+
741+
### How It Works
742+
743+
The CDN feature uses a lazy-loading approach for optimal performance:
744+
745+
1. **During Twig rendering**: When `cdn_url()` is called, only metadata (the original Storyblok URL) is stored locally. No download occurs at this point, keeping page rendering fast.
746+
747+
2. **On first browser request**: When a browser requests the CDN URL, the controller downloads the file from Storyblok, stores it locally with enriched metadata (content type, etag, expiration), and serves it.
748+
749+
3. **On subsequent requests**: The file is served directly from local storage with appropriate caching headers.
750+
751+
This approach ensures that page rendering is not blocked by asset downloads, even when dealing with many images.
752+
753+
### Custom Storage Implementation
754+
755+
By default, assets are stored on the filesystem. You can implement your own storage by creating a class that implements `CdnStorageInterface`:
756+
757+
```php
758+
use Storyblok\Bundle\Cdn\Storage\CdnStorageInterface;
759+
use Symfony\Component\DependencyInjection\Attribute\AsAlias;
760+
761+
#[AsAlias(CdnStorageInterface::class)]
762+
final class RedisCdnStorage implements CdnStorageInterface
763+
{
764+
// Implement the interface methods
765+
}
766+
```
767+
768+
Then configure the bundle to use custom storage:
769+
770+
```yaml
771+
# config/packages/storyblok.yaml
772+
storyblok:
773+
cdn:
774+
storage:
775+
type: custom # This removes the built-in filesystem storage
776+
```
777+
778+
> [!NOTE]
779+
> When using `type: custom`, the `path` option should not be set. The built-in cleanup command is also removed since it's specific to filesystem storage.
780+
629781
### Image Transformation
630782

631783
The bundle provides a `storyblok_image` Twig filter to convert Storyblok Assets to Image objects with optional resizing. This filter integrates with the [storyblok/php-image-service](https://github.com/storyblok/php-image-service) library and returns an immutable `Image` instance that you can further transform using the fluent API.

composer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
"storyblok/php-image-service": ">=0.5.0",
1818
"storyblok/php-tiptap-extension": "^1.0",
1919
"symfony/config": "^6.0 || ^7.0 || ^8.0",
20+
"symfony/console": "^6.0 || ^7.0 || ^8.0",
2021
"symfony/dependency-injection": "^6.0 || ^7.0 || ^8.0",
22+
"symfony/filesystem": "^6.0 || ^7.0 || ^8.0",
23+
"symfony/finder": "^6.0 || ^7.0 || ^8.0",
2124
"symfony/framework-bundle": "^6.0 || ^7.0 || ^8.0",
2225
"symfony/http-client": "^6.0 || ^7.0 || ^8.0",
2326
"symfony/http-kernel": "^6.0 || ^7.0 || ^8.0",

config/routes.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@
77
return function (RoutingConfigurator $routes): void {
88
$routes->import('@StoryblokBundle/config/routes/webhook.php');
99
$routes->import('@StoryblokBundle/config/routes/content_type.php');
10+
$routes->import('@StoryblokBundle/config/routes/cdn.php');
1011
};

config/routes/cdn.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Symfony\Component\Routing\Loader\Configurator;
6+
7+
use Storyblok\Bundle\Controller\CdnController;
8+
use Storyblok\Bundle\Routing\Route;
9+
use Symfony\Component\HttpFoundation\Request;
10+
11+
return function (RoutingConfigurator $routes): void {
12+
$routes->add(Route::CDN, '/f/{id}/{filename}.{extension}')
13+
->controller(CdnController::class)
14+
->methods([Request::METHOD_GET, Request::METHOD_HEAD])
15+
->requirements([
16+
'id' => '[a-f0-9]{16}',
17+
'extension' => 'jpe?g|png|gif|webp|svg|avif|pdf|mp4',
18+
])
19+
->options([
20+
'priority' => 1,
21+
]);
22+
};

config/services.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@
1919
use Storyblok\Bundle\Block\BlockRegistry;
2020
use Storyblok\Bundle\Block\Renderer\BlockRenderer;
2121
use Storyblok\Bundle\Block\Renderer\RendererInterface;
22+
use Storyblok\Bundle\Cdn\CdnUrlGenerator;
23+
use Storyblok\Bundle\Cdn\CdnUrlGeneratorInterface;
24+
use Storyblok\Bundle\Command\CdnCleanupCommand;
25+
use Storyblok\Bundle\Cdn\Download\AssetDownloader;
26+
use Storyblok\Bundle\Cdn\Download\FileDownloaderInterface;
27+
use Storyblok\Bundle\Cdn\Storage\CdnFilesystemStorage;
28+
use Storyblok\Bundle\Cdn\Storage\CdnStorageInterface;
29+
use Storyblok\Bundle\Cdn\Storage\TraceableCdnStorage;
30+
use Storyblok\Bundle\DataCollector\CdnCollector;
2231
use Storyblok\Bundle\ContentType\ContentTypeControllerRegistry;
2332
use Storyblok\Bundle\ContentType\ContentTypeRegistry;
2433
use Storyblok\Bundle\ContentType\ContentTypeRegistryInterface;
@@ -27,12 +36,14 @@
2736
use Storyblok\Bundle\ContentType\Listener\GlobalCachingListener;
2837
use Storyblok\Bundle\ContentType\Listener\ResolveControllerListener;
2938
use Storyblok\Bundle\ContentType\Listener\StoryNotFoundExceptionListener;
39+
use Storyblok\Bundle\Controller\CdnController;
3040
use Storyblok\Bundle\Controller\WebhookController;
3141
use Storyblok\Bundle\DataCollector\StoryblokCollector;
3242
use Storyblok\Bundle\Listener\UpdateProfilerListener;
3343
use Storyblok\Bundle\Tiptap\DefaultEditorBuilder;
3444
use Storyblok\Bundle\Tiptap\EditorBuilderInterface;
3545
use Storyblok\Bundle\Twig\BlockExtension;
46+
use Storyblok\Bundle\Twig\CdnExtension;
3647
use Storyblok\Bundle\Twig\ImageExtension;
3748
use Storyblok\Bundle\Twig\LiveEditorExtension;
3849
use Storyblok\Bundle\Twig\RichTextExtension;
@@ -110,6 +121,19 @@
110121
'priority' => 255,
111122
])
112123

124+
->set(TraceableCdnStorage::class)
125+
->args([
126+
'$decorated' => service(CdnFilesystemStorage::class),
127+
])
128+
129+
->set(CdnCollector::class)
130+
->args([
131+
'$storage' => service(TraceableCdnStorage::class),
132+
])
133+
->tag('data_collector', [
134+
'priority' => 254,
135+
])
136+
113137
->set(UpdateProfilerListener::class)
114138
->tag('kernel.event_listener', [
115139
'event' => KernelEvents::RESPONSE,
@@ -181,5 +205,36 @@
181205

182206
->set(ImageExtension::class)
183207
->tag('twig.extension')
208+
209+
->set(CdnController::class)
210+
->args([
211+
'$storage' => service(CdnStorageInterface::class),
212+
'$downloader' => service(FileDownloaderInterface::class),
213+
'$public' => abstract_arg('public cache directive'),
214+
'$maxAge' => abstract_arg('max-age cache directive'),
215+
'$smaxAge' => abstract_arg('smaxage cache directive'),
216+
])
217+
->tag('controller.service_arguments')
218+
219+
->set(AssetDownloader::class)
220+
->alias(FileDownloaderInterface::class, AssetDownloader::class)
221+
222+
->set(CdnFilesystemStorage::class)
223+
->args([
224+
'$storagePath' => abstract_arg('CDN storage path'),
225+
])
226+
->alias(CdnStorageInterface::class, CdnFilesystemStorage::class)
227+
228+
->set(CdnUrlGenerator::class)
229+
->alias(CdnUrlGeneratorInterface::class, CdnUrlGenerator::class)
230+
231+
->set(CdnExtension::class)
232+
->tag('twig.extension')
233+
234+
->set(CdnCleanupCommand::class)
235+
->args([
236+
'$storagePath' => abstract_arg('CDN storage path'),
237+
])
238+
->tag('console.command')
184239
;
185240
};

src/Cdn/CdnUrlGenerator.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of storyblok/symfony-bundle.
7+
*
8+
* (c) Storyblok GmbH <info@storyblok.com>
9+
* in cooperation with SensioLabs Deutschland <info@sensiolabs.de>
10+
*
11+
* For the full copyright and license information, please view the LICENSE
12+
* file that was distributed with this source code.
13+
*/
14+
15+
namespace Storyblok\Bundle\Cdn;
16+
17+
use Storyblok\Api\Domain\Type\Asset;
18+
use Storyblok\Bundle\Cdn\Domain\AssetInfo;
19+
use Storyblok\Bundle\Cdn\Domain\CdnFileMetadata;
20+
use Storyblok\Bundle\Cdn\Storage\CdnStorageInterface;
21+
use Storyblok\Bundle\Routing\Route;
22+
use Storyblok\ImageService\Image;
23+
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
24+
25+
/**
26+
* @author Silas Joisten <silasjoisten@proton.me>
27+
* @author Stiven Llupa <stiven.llupa@gmail.com>
28+
*/
29+
final readonly class CdnUrlGenerator implements CdnUrlGeneratorInterface
30+
{
31+
public function __construct(
32+
private CdnStorageInterface $storage,
33+
private UrlGeneratorInterface $urlGenerator,
34+
) {
35+
}
36+
37+
public function generate(Asset|Image $asset, int $referenceType = UrlGeneratorInterface::ABSOLUTE_URL): string
38+
{
39+
$assetInfo = new AssetInfo($asset);
40+
41+
if (!$this->storage->hasMetadata($assetInfo->id, $assetInfo->fullFilename)) {
42+
$this->storage->setMetadata($assetInfo->id, $assetInfo->fullFilename, new CdnFileMetadata(originalUrl: $assetInfo->url));
43+
}
44+
45+
return $this->urlGenerator->generate(Route::CDN, [
46+
'id' => $assetInfo->id->value,
47+
'filename' => $assetInfo->filename,
48+
'extension' => $assetInfo->extension,
49+
], $referenceType);
50+
}
51+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of storyblok/symfony-bundle.
7+
*
8+
* (c) Storyblok GmbH <info@storyblok.com>
9+
* in cooperation with SensioLabs Deutschland <info@sensiolabs.de>
10+
*
11+
* For the full copyright and license information, please view the LICENSE
12+
* file that was distributed with this source code.
13+
*/
14+
15+
namespace Storyblok\Bundle\Cdn;
16+
17+
use Storyblok\Api\Domain\Type\Asset;
18+
use Storyblok\ImageService\Image;
19+
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
20+
21+
/**
22+
* @author Silas Joisten <silasjoisten@proton.me>
23+
* @author Stiven Llupa <stiven.llupa@gmail.com>
24+
*/
25+
interface CdnUrlGeneratorInterface
26+
{
27+
/**
28+
* @param UrlGeneratorInterface::* $referenceType
29+
*/
30+
public function generate(Asset|Image $asset, int $referenceType = UrlGeneratorInterface::ABSOLUTE_URL): string;
31+
}

0 commit comments

Comments
 (0)