Skip to content

Commit 3e84f28

Browse files
committed
[FEATURE] Introduce cloudinary web hook handler
1 parent f0e7bac commit 3e84f28

7 files changed

Lines changed: 350 additions & 24 deletions

File tree

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
<?php
2+
3+
namespace Visol\Cloudinary\Controller;
4+
5+
/*
6+
* This file is part of the Visol/Cloudinary project under GPLv2 or later.
7+
*
8+
* For the full copyright and license information, please read the
9+
* LICENSE.md file that was distributed with this source code.
10+
*/
11+
12+
use Psr\Http\Message\ResponseInterface;
13+
use TYPO3\CMS\Core\Cache\CacheManager;
14+
use TYPO3\CMS\Core\Database\ConnectionPool;
15+
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
16+
use TYPO3\CMS\Core\Log\Logger;
17+
use TYPO3\CMS\Core\Log\LogManager;
18+
use TYPO3\CMS\Core\Resource\File;
19+
use TYPO3\CMS\Core\Resource\ProcessedFileRepository;
20+
use TYPO3\CMS\Core\Resource\ResourceFactory;
21+
use TYPO3\CMS\Core\Resource\ResourceStorage;
22+
use TYPO3\CMS\Core\Utility\GeneralUtility;
23+
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
24+
use Visol\Cloudinary\Exceptions\CloudinaryNotFoundException;
25+
use Visol\Cloudinary\Exceptions\PublicIdMissingException;
26+
use Visol\Cloudinary\Exceptions\UnknownRequestTypeException;
27+
use Visol\Cloudinary\Services\CloudinaryPathService;
28+
use Visol\Cloudinary\Services\CloudinaryResourceService;
29+
use Visol\Cloudinary\Services\CloudinaryScanService;
30+
31+
class CloudinaryWebHookController extends ActionController
32+
{
33+
34+
protected const NOTIFICATION_TYPE_UPLOAD = 'upload';
35+
protected const NOTIFICATION_TYPE_RENAME = 'rename';
36+
protected const NOTIFICATION_TYPE_DELETE = 'delete';
37+
38+
protected CloudinaryResourceService $cloudinaryResourceService;
39+
protected CloudinaryScanService $scanService;
40+
protected CloudinaryPathService $cloudinaryPathService;
41+
protected ProcessedFileRepository $processedFileRepository;
42+
protected ResourceStorage $storage;
43+
44+
protected function initializeAction(): void
45+
{
46+
$this->checkEnvironment();
47+
48+
/** @var ResourceFactory $resourceFactory */
49+
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
50+
51+
$storage = $resourceFactory->getStorageObject((int)$this->settings['storage']);
52+
$this->cloudinaryResourceService = GeneralUtility::makeInstance(
53+
CloudinaryResourceService::class,
54+
$storage,
55+
);
56+
57+
$this->scanService = GeneralUtility::makeInstance(
58+
CloudinaryScanService::class,
59+
$storage
60+
);
61+
62+
$this->cloudinaryPathService = GeneralUtility::makeInstance(
63+
CloudinaryPathService::class,
64+
$storage->getConfiguration()
65+
);
66+
67+
$this->storage = $storage;
68+
69+
$this->processedFileRepository = GeneralUtility::makeInstance(ProcessedFileRepository::class);
70+
}
71+
72+
public function processAction(): ResponseInterface
73+
{
74+
$parsedBody = (string)file_get_contents('php://input');
75+
$payload = json_decode($parsedBody, true);
76+
self::getLogger()->debug($parsedBody);
77+
78+
if ($this->shouldStopProcessing($payload)) {
79+
return $this->sendResponse(['result' => 'ok', 'message' => 'Nothing to do...']);
80+
}
81+
82+
try {
83+
[$requestType, $publicIds] = $this->getRequestInfo($payload);
84+
85+
self::getLogger()->debug(sprintf('Request file "%s". Start cache flushing...', $requestType));
86+
87+
foreach ($publicIds as $publicId) {
88+
$cloudinaryResource = $this->getCloudinaryResource($publicId);
89+
90+
// #. retrieve the source file
91+
$file = $this->getFile($cloudinaryResource);
92+
93+
// #. flush the process files
94+
$this->clearProcessedFiles($file);
95+
96+
// #. flush cache pages
97+
$this->clearCachePages($file);
98+
}
99+
} catch (\Exception $e) {
100+
return $this->sendResponse([
101+
'result' => 'ko',
102+
'message' => $e->getMessage(),
103+
]);
104+
}
105+
106+
return $this->sendResponse(['result' => 'ok', 'message' => 'Cache flushed']);
107+
}
108+
109+
protected function getFile(array $cloudinaryResource): File
110+
{
111+
$fileIdentifier = $this->cloudinaryPathService->computeFileIdentifier($cloudinaryResource);
112+
return $this->storage->getFileByIdentifier($fileIdentifier);
113+
}
114+
115+
protected function getRequestInfo(array $payload): array
116+
{
117+
if ($this->isRequestUploadOverwrite($payload)) {
118+
$requestType = self::NOTIFICATION_TYPE_UPLOAD;
119+
$publicIds = [$payload['public_id']];
120+
} elseif ($this->isRequestRename($payload)) {
121+
$requestType = self::NOTIFICATION_TYPE_RENAME;
122+
$publicIds = [$payload['from_public_id']];
123+
//$nextPublicId = $payload['to_public_id'];
124+
} elseif ($this->isRequestDelete($payload)) {
125+
$requestType = self::NOTIFICATION_TYPE_DELETE;
126+
$publicIds = [];
127+
foreach ($payload['resources'] as $resource) {
128+
$publicIds[] = $resource['public_id'];
129+
}
130+
} else {
131+
throw new UnknownRequestTypeException('Unknown request type', 1677860080);
132+
}
133+
134+
if (empty($publicIds)) {
135+
throw new PublicIdMissingException('Missing public id', 1677860090);
136+
}
137+
138+
return [$requestType, $publicIds,];
139+
}
140+
141+
protected function getCloudinaryResource(string $publicId): array
142+
{
143+
$cloudinaryResource = $this->cloudinaryResourceService->getResource($publicId);
144+
145+
// The resource does not exist, time to fetch
146+
if (!$cloudinaryResource) {
147+
$result = $this->scanService->scanOne($publicId);
148+
if (!$result) {
149+
$message = sprintf('I could not find a corresponding resource for public id %s', $publicId);
150+
throw new CloudinaryNotFoundException($message, 1677859470);
151+
}
152+
$cloudinaryResource = $this->cloudinaryResourceService->getResource($publicId);
153+
}
154+
155+
return $cloudinaryResource;
156+
}
157+
158+
protected function clearProcessedFiles(File $file): void
159+
{
160+
$processedFiles = $this->processedFileRepository->findAllByOriginalFile($file);
161+
162+
foreach ($processedFiles as $processedFile) {
163+
$processedFile->getStorage()->setEvaluatePermissions(false);
164+
$processedFile->delete();
165+
}
166+
}
167+
168+
protected function clearCachePages(File $file): void
169+
{
170+
$tags = [];
171+
foreach ($this->findPagesWithFileReferences($file) as $page) {
172+
$tags[] = 'pageId_' . $page['pid'];
173+
}
174+
175+
GeneralUtility::makeInstance(CacheManager::class)
176+
->flushCachesInGroupByTags('pages', $tags);
177+
}
178+
179+
protected function findPagesWithFileReferences(File $file): array
180+
{
181+
$queryBuilder = $this->getQueryBuilder('sys_file_reference');
182+
return $queryBuilder
183+
->select('pid')
184+
->from('sys_file_reference')
185+
->groupBy('pid') // no support for distinct
186+
->andWhere(
187+
'pid > 0',
188+
'uid_local = ' . $file->getUid()
189+
)
190+
->execute()
191+
->fetchAllAssociative();
192+
}
193+
194+
protected function shouldStopProcessing(mixed $payload): bool
195+
{
196+
return !($this->isRequestUploadOverwrite($payload) || $this->isRequestRename($payload));
197+
}
198+
199+
protected function isRequestUploadOverwrite(mixed $payload): bool
200+
{
201+
return is_array($payload) &&
202+
array_key_exists('notification_type', $payload) &&
203+
array_key_exists('overwritten', $payload) &&
204+
$payload['notification_type'] === self::NOTIFICATION_TYPE_UPLOAD
205+
&& $payload['overwritten'];
206+
}
207+
208+
protected function isRequestRename(mixed $payload): bool
209+
{
210+
return is_array($payload) &&
211+
array_key_exists('notification_type', $payload) &&
212+
$payload['notification_type'] === self::NOTIFICATION_TYPE_RENAME;
213+
}
214+
215+
protected function isRequestDelete(mixed $payload): bool
216+
{
217+
return is_array($payload) &&
218+
array_key_exists('notification_type', $payload) &&
219+
$payload['notification_type'] === self::NOTIFICATION_TYPE_DELETE;
220+
}
221+
222+
protected function sendResponse(array $data): ResponseInterface
223+
{
224+
return $this->jsonResponse(
225+
json_encode($data)
226+
);
227+
}
228+
229+
protected function checkEnvironment(): void
230+
{
231+
$storageUid = $this->settings['storage'] ?? 0;
232+
if ($storageUid <= 0) {
233+
throw new \RuntimeException('Check your configuration while calling the cloudinary web hook. I am missing a storage id', 1677583654);
234+
}
235+
}
236+
237+
protected function getQueryBuilder($tableName): QueryBuilder
238+
{
239+
/** @var ConnectionPool $connectionPool */
240+
$connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
241+
return $connectionPool->getQueryBuilderForTable($tableName);
242+
}
243+
244+
protected static function getLogger(): Logger
245+
{
246+
/** @var Logger $logger */
247+
static $logger = null;
248+
if ($logger === null) {
249+
$logger = GeneralUtility::makeInstance(LogManager::class)->getLogger(__CLASS__);
250+
}
251+
return $logger;
252+
}
253+
254+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace Visol\Cloudinary\Exceptions;
4+
5+
/*
6+
* This file is part of the Visol/Cloudinary project under GPLv2 or later.
7+
*
8+
* For the full copyright and license information, please read the
9+
* LICENSE.md file that was distributed with this source code.
10+
*/
11+
12+
use TYPO3\CMS\Core\Exception;
13+
14+
class CloudinaryNotFoundException extends Exception {
15+
16+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace Visol\Cloudinary\Exceptions;
4+
5+
/*
6+
* This file is part of the Visol/Cloudinary project under GPLv2 or later.
7+
*
8+
* For the full copyright and license information, please read the
9+
* LICENSE.md file that was distributed with this source code.
10+
*/
11+
12+
use TYPO3\CMS\Core\Exception;
13+
14+
class PublicIdMissingException extends Exception {
15+
16+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace Visol\Cloudinary\Exceptions;
4+
5+
/*
6+
* This file is part of the Visol/Cloudinary project under GPLv2 or later.
7+
*
8+
* For the full copyright and license information, please read the
9+
* LICENSE.md file that was distributed with this source code.
10+
*/
11+
12+
use TYPO3\CMS\Core\Exception;
13+
14+
class UnknownRequestTypeException extends Exception {
15+
16+
}

Configuration/TypoScript/setup.typoscript

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ page_1573555440 {
99
xhtml_cleaning = 0
1010
admPanel = 0
1111
disableAllHeaderCode = 1
12-
additionalHeaders.10.header = Content-type:text/html
1312
}
1413
10 = COA_INT
1514
10 {
@@ -18,10 +17,13 @@ page_1573555440 {
1817
userFunc = TYPO3\CMS\Extbase\Core\Bootstrap->run
1918
vendorName = Visol
2019
extensionName = Cloudinary
21-
pluginName = Cache
20+
pluginName = WebHook
21+
settings {
22+
storage = ### !!! Add a storage uid
23+
}
2224
switchableControllerActions {
23-
CloudinaryScan {
24-
1 = scan
25+
CloudinaryWebHook {
26+
1 = process
2527
}
2628
}
2729
}

README.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -198,16 +198,26 @@ Available targets:
198198
Web Hook
199199
--------
200200

201-
Whenever uploading or editing a file through the Cloudinary Manager you can configure an URL
202-
as a web hook to be called to invalidate the cache in TYPO3.
203-
This is highly recommended to keep the data consistent between Cloudinary and TYPO3.
201+
202+
Whenever uploading or editing a file in the cloudinary library, you can configure in the cloudinary settings a URL to
203+
be called as a web hook. This is recommended to keep the data consistent between Cloudinary and TYPO3. When overridding
204+
or moving a file across folders, cloudinary will inform TYPO3 that something has changed.
205+
206+
It will basically:
207+
208+
* invalidate the processed files
209+
* invalidate the page cache where the the file is involved.
210+
204211

205212
```shell script
206213
https://domain.tld/?type=1573555440
207214
```
208215

209-
**Beware**: Do not rename, move or delete files in the Cloudinary Media Library. TYPO3 will not know about the change.
210-
We may need to implement a web hook. For now, it is necessary to perform these action in the File module in the Backend.
216+
This, however, will not work out of the box and requires some manual configuration.
217+
Refer to the file ext:cloudinary/Configuration/TypoScript/setup.typoscript where we define a custom type.
218+
This is an example TypoScript file. Make sure that the file is loaded, and that you have defined a storage UID.
219+
Your system may contain multiple Cloudinary storages, and each web hook must refer to its own Cloudinary storage.
220+
Eventually you will end up having as many config as you have cloudinary storage.
211221

212222
Source of inspiration
213223
---------------------

0 commit comments

Comments
 (0)