Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions bundle/Controller/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Netgen\Bundle\RemoteMediaBundle\Controller;

use Netgen\RemoteMedia\Core\Provider\Cloudinary\Provider as CloudinaryProvider;
use Netgen\RemoteMedia\Core\Resolver\Variation as VariationResolver;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
Expand All @@ -18,6 +19,8 @@
private readonly RouterInterface $router,
private readonly TranslatorInterface $translator,
private readonly VariationResolver $variationResolver,
private readonly string $folderMode,
private bool $appendFolderPath,
) {}

public function __invoke(Request $request): JsonResponse
Expand All @@ -28,6 +31,7 @@
'availableVariations' => $this->resolveAvailableVariations($request),
'allVariations' => $this->resolveAllVariations($request),
'uploadContext' => $this->resolveUploadContext($request),
'folderScopedUploads' => $this->folderMode === CloudinaryProvider::FOLDER_MODE_FIXED || $this->appendFolderPath,

Check failure on line 34 in bundle/Controller/Configuration.php

View workflow job for this annotation

GitHub Actions / phpstan

Access to constant FOLDER_MODE_FIXED on an unknown class Netgen\RemoteMedia\Core\Provider\Cloudinary\Provider.
]);
}

Expand Down Expand Up @@ -133,6 +137,7 @@
'upload_checkbox_overwrite' => $this->translator->trans('ngrm.edit.vue.upload.checkbox.overwrite', [], 'ngremotemedia'),
'upload_placeholder_new_folder' => $this->translator->trans('ngrm.edit.vue.upload.placeholder.new_folder', [], 'ngremotemedia'),
'upload_error_existing_resource' => $this->translator->trans('ngrm.edit.vue.upload.error.existing_resource', [], 'ngremotemedia'),
'upload_error_existing_resource_in_folder' => $this->translator->trans('ngrm.edit.vue.upload.error.existing_resource_in_folder', [], 'ngremotemedia'),
'upload_error_unsupported_resource_type' => $this->translator->trans('ngrm.edit.vue.upload.error.unsupported_resource_type', [], 'ngremotemedia'),
];
}
Expand Down
3 changes: 3 additions & 0 deletions bundle/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ private function addCloudinaryConfiguration(ArrayNodeDefinition $rootNode): void
->booleanNode('unique_filenames')
->defaultValue(false)
->end()
->booleanNode('append_folder_path')
->defaultValue(false)
->end()
->scalarNode('encryption_key')
->defaultNull()
->end()
Expand Down
5 changes: 5 additions & 0 deletions bundle/DependencyInjection/NetgenRemoteMediaExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ public function load(array $configs, ContainerBuilder $container): void
$config['cloudinary']['unique_filenames'],
);

$container->setParameter(
'netgen_remote_media.cloudinary.append_folder_path',
$config['cloudinary']['append_folder_path'],
);

if (isset($config['templates'])) {
if (isset($config['templates']['view_resource'])) {
$container->setParameter(
Expand Down
2 changes: 2 additions & 0 deletions bundle/Resources/config/services/controllers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,5 @@ services:
- '@router'
- '@translator'
- '@netgen_remote_media.resolver.variation'
- '%netgen_remote_media.cloudinary.folder_mode%'
- '%netgen_remote_media.cloudinary.append_folder_path%'
1 change: 1 addition & 0 deletions bundle/Resources/config/services/core.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ services:
- '%netgen_remote_media.cloudinary.folder_mode%'
- '%netgen_remote_media.cloudinary.append_extension%'
- '%netgen_remote_media.cloudinary.unique_filenames%'
- '%netgen_remote_media.cloudinary.append_folder_path%'

netgen_remote_media.provider.cloudinary.resolver.search_expression:
class: Netgen\RemoteMedia\Core\Provider\Cloudinary\Resolver\SearchExpression
Expand Down
2 changes: 1 addition & 1 deletion bundle/Resources/public/css/remotemedia.css

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions bundle/Resources/public/js/remotemedia-vendors.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion bundle/Resources/public/js/remotemedia.js

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion bundle/Resources/translations/ngremotemedia.en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ ngrm:
new_folder: 'New folder name'
error:
missing_file: 'Missing file to upload'
existing_resource: 'Different resource with same name already exists in this folder! Change folder, filename, use overwrite or use existing resource:'
existing_resource: 'Different resource with same name already exists! Rename it, use overwrite or use existing resource:'
existing_resource_in_folder: 'Different resource with same name already exists in folder "%folder%"! Change folder, rename it, use overwrite or use existing resource:'
unsupported_resource_type: 'Resource has been uploaded but it\s type is not supported so it can be used here! Supported types: '
invalid_visibility: 'Invalid visibility option "%visibility%", supported options: "%supported_visibilities%"'
file_upload_failed: 'File upload failed; the file might be too big or corrupted. Check your server file upload size settings.'
3 changes: 2 additions & 1 deletion bundle/Resources/translations/ngremotemedia.hr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ ngrm:
new_folder: 'Ime nove mape'
error:
missing_file: 'Nedostaje datoteka za učitavanje'
existing_resource: 'Drugi resurs s istim imenom već postoji u ovoj mapi! Promjenite mapu, ime datoteke, odaberite opciju za prepisivanje ili upotrijebite postojeći resurs:'
existing_resource: 'Drugi resurs s istim imenom već postoji! Promjenite ime datoteke, odaberite opciju za prepisivanje ili upotrijebite postojeći resurs:'
existing_resource_in_folder: 'Drugi resurs s istim imenom već postoji u ovoj mapi! Promjenite mapu, ime datoteke, odaberite opciju za prepisivanje ili upotrijebite postojeći resurs:'
unsupported_resource_type: 'Resurs je uspješno učitan no njegov tip nije podržan na ovome mjestu! Podržani tipovi: '
invalid_visibility: 'Odabrana je nepodržana vidljivost "%visibility%", podržane vidljivosti: "%supported_visibilities%"'
file_upload_failed: 'Učitavanje datoteke neuspješno; datoteka je možda prevelika ili neispravna. Provjerite postavke servera za učitavanje datoteka.'
3 changes: 2 additions & 1 deletion bundle/Resources/translations/ngremotemedia.no.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ ngrm:
new_folder: 'New folder name'
error:
missing_file: 'Missing file to upload'
existing_resource: 'Different resource with same name already exists in this folder! Change folder, filename, use overwrite or use existing resource:'
existing_resource: 'Different resource with same name already exists! Rename it, use overwrite or use existing resource:'
existing_resource_in_folder: 'Different resource with same name already exists in folder "%folder%"! Change folder, rename it, use overwrite or use existing resource:'
unsupported_resource_type: "Resource has been uploaded but it's type is not supported so it can't be used here! Supported types: "
invalid_visibility: 'Invalid visibility option "%visibility%", supported options: "%supported_visibilities%"'
file_upload_failed: 'File upload failed; the file might be too big or corrupted. Check your server file upload size settings.'
6 changes: 6 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Netgen Remote Media Bundle changelog

## 3.0.0

### Added

* Added `cloudinary.append_folder_path` option to avoid public ID collisions in dynamic folder mode

## 1.1.11

### Fixed
Expand Down
29 changes: 29 additions & 0 deletions docs/INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ All new accounts are automatically set to `dynamic` mode and this can't be chang

So in order to support both modes, there's a parameter with the same name here, and it has to be properly configured. You can check your mode in your Cloudinary dashboard.

**WARNING:** Since folder is now just a metadata in `dynamic` mode, if you change the folder of an existing resource, its public ID will remain the same, which means that the URL to access that resource will also remain the same. This means that you can't upload the same file to a different folder because the public ID will be the same and Cloudinary will throw an error that resource with the same public ID already exists. If you want to upload the same file to a different folder, you have to rename it first.

If that's an issue in your project, and you want to be able to upload the same file to different folders without manual renaming, you have two options:

* enable `unique_filenames` option ([see below](#unique-filenames))
* enable `append_folder_path` option ([see below](#append-folder-path-to-public-id))

```yaml
netgen_remote_media:
cloudinary:
Expand Down Expand Up @@ -118,6 +125,28 @@ netgen_remote_media:

**WARNING:** if you enable this, all resources will be always unique and you can easily create a mess on your cloud by uploading the same identical file multiple times.

#### Append folder path to public ID

In Cloudinary's `dynamic` folder mode, the folder is just metadata — the public ID is independent of the folder. This means that uploading the same file to different folders will fail because the public ID already exists.

`append_folder_path` option prefixes the public ID with the folder path (`folder/filename`), so the same file in different folders gets different public IDs, while real duplicates inside the same folder still fail (which is the desired behavior).

This can be configured with:

```yaml
netgen_remote_media:
cloudinary:
append_folder_path: true
```

(default: `false`)

**Note:** this option only applies in `dynamic` folder mode — in `fixed` mode it is silently ignored because Cloudinary already includes the folder in the public ID.

**WARNING:** if a resource is later moved to another folder via the Cloudinary UI, its public ID is not rewritten by Cloudinary, so the folder prefix in the public ID becomes stale (reflects the original upload folder, not the current one). URLs keep working, but the public ID no longer matches the actual folder.

**WARNING:** combining with `unique_filenames: true` is allowed but redundant — both flags will be active.

### Upload prefix

If you need to change Cloudinary API url (to use eg. GEO specific URLs), there's a parameter `upload_prefix` (set to `https://api.cloudinary.com` by default):
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/UploadModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ export default {
}
}).catch(error => {
if (error.response.status === 409) {
this.error = this.config.translations.upload_error_existing_resource;
this.error = this.config.folderScopedUploads
? this.config.translations.upload_error_existing_resource_in_folder.replace('%folder%', this.selectedFolder)
: this.config.translations.upload_error_existing_resource;
this.existingResourceButton = true;
this.existingResource = error.response.data;
this.loading = false;
Expand Down
17 changes: 17 additions & 0 deletions lib/Core/Provider/Cloudinary/Resolver/UploadOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
use function in_array;
use function is_string;
use function pathinfo;
use function preg_replace;
use function trim;

final class UploadOptions
{
Expand All @@ -19,6 +21,7 @@ public function __construct(
private string $folderMode,
private bool $appendExtension,
private bool $uniqueFilenames,
private bool $appendFolderPath,
) {}

public function resolve(ResourceStruct $resourceStruct): array
Expand Down Expand Up @@ -49,6 +52,15 @@ public function resolve(ResourceStruct $resourceStruct): array

if ($resourceStruct->getFolder() && $this->folderMode === CloudinaryProvider::FOLDER_MODE_DYNAMIC) {
$options['asset_folder'] = $resourceStruct->getFolder()->getPath();

if ($this->appendFolderPath === true) {
$normalized = $this->normalizeFolderPath($resourceStruct->getFolder()->getPath());
if ($normalized !== '') {
$options['public_id'] = $normalized . '/' . $filenameOverride;
unset($options['filename_override']);
$options['use_filename'] = false;
}
}
}

if ($resourceStruct->getFolder() && $this->folderMode === CloudinaryProvider::FOLDER_MODE_FIXED) {
Expand All @@ -58,6 +70,11 @@ public function resolve(ResourceStruct $resourceStruct): array
return $options;
}

private function normalizeFolderPath(string $path): string
{
return trim(preg_replace('#/+#', '/', $path), '/');
}

/**
* @return array<string, string>
*/
Expand Down
85 changes: 85 additions & 0 deletions tests/bundle/Controller/ConfigurationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace Netgen\Bundle\RemoteMediaBundle\Tests\Controller;

use Netgen\Bundle\RemoteMediaBundle\Controller\Configuration as ConfigurationController;
use Netgen\RemoteMedia\Core\Provider\Cloudinary\CloudinaryProvider;
use Netgen\RemoteMedia\Core\Resolver\Variation as VariationResolver;
use Netgen\RemoteMedia\Core\Transformation\Registry;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

use function json_decode;

#[CoversClass(ConfigurationController::class)]
final class ConfigurationTest extends TestCase
{
private MockObject|RouterInterface $routerMock;
private MockObject|TranslatorInterface $translatorMock;
private MockObject|VariationResolver $variationResolverMock;

protected function setUp(): void
{
$this->routerMock = $this->createMock(RouterInterface::class);
$this->translatorMock = $this->createMock(TranslatorInterface::class);
$this->variationResolverMock = new VariationResolver(
new Registry(),
null,
[],
);
}

/**
* @dataProvider folderScopedUploadsDataProvider
*/
public function testFolderScopedUploads(string $folderMode, bool $appendFolderPath, bool $expected): void
{
$controller = new ConfigurationController(
$this->routerMock,
$this->translatorMock,
$this->variationResolverMock,
$folderMode,
$appendFolderPath,
);

$response = $controller->__invoke(new Request());
$data = json_decode((string) $response->getContent(), true);

self::assertSame($expected, $data['folderScopedUploads']);
}

public static function folderScopedUploadsDataProvider(): iterable
{
return [
[CloudinaryProvider::FOLDER_MODE_FIXED, false, true],
[CloudinaryProvider::FOLDER_MODE_DYNAMIC, false, false],
[CloudinaryProvider::FOLDER_MODE_DYNAMIC, true, true],
[CloudinaryProvider::FOLDER_MODE_FIXED, true, true],
];
}

public function testTranslationsExist(): void
{
$controller = new ConfigurationController(
$this->routerMock,
$this->translatorMock,
$this->variationResolverMock,
CloudinaryProvider::FOLDER_MODE_FIXED,
false,
);

$this->translatorMock->method('trans')->willReturn('translated string');

Check failure on line 77 in tests/bundle/Controller/ConfigurationTest.php

View workflow job for this annotation

GitHub Actions / phpstan-tests

Trying to mock an undefined method trans() on class Symfony\Contracts\Translation\TranslatorInterface.

$response = $controller->__invoke(new Request());
$data = json_decode((string) $response->getContent(), true);

self::assertArrayHasKey('upload_error_existing_resource', $data['translations']);
self::assertArrayHasKey('upload_error_existing_resource_in_folder', $data['translations']);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public function testItSetsValidContainerParameters(): void
$this->assertContainerBuilderHasParameter('netgen_remote_media.encryption_key', 'dsf45z45hh45f43f43f');
$this->assertContainerBuilderHasParameter('netgen_remote_media.cloudinary.append_extension', true);
$this->assertContainerBuilderHasParameter('netgen_remote_media.cloudinary.unique_filenames', false);
$this->assertContainerBuilderHasParameter('netgen_remote_media.cloudinary.append_folder_path', false);

$this->assertContainerBuilderHasParameter(
'netgen_remote_media.named_remote_resources',
Expand Down Expand Up @@ -94,6 +95,17 @@ public function testWithDisabledCloudinaryCachingAndEnabledLogging(): void
$this->assertContainerBuilderHasAlias('netgen_remote_media.provider.cloudinary.gateway', 'netgen_remote_media.provider.cloudinary.gateway.inner');
}

public function testWithEnabledAppendFolderPath(): void
{
$this->load([
'cloudinary' => [
'append_folder_path' => true,
],
]);

$this->assertContainerBuilderHasParameter('netgen_remote_media.cloudinary.append_folder_path', true);
}

protected function getContainerExtensions(): array
{
return [
Expand All @@ -118,6 +130,7 @@ protected function getMinimalConfiguration(): array
'log_requests' => false,
'append_extension' => true,
'unique_filenames' => false,
'append_folder_path' => false,
'encryption_key' => 'dsf45z45hh45f43f43f',
],
'image_variations' => [
Expand Down
Loading
Loading