Skip to content

Commit 8ddddd7

Browse files
committed
[FEATURE] Introduce cloudinary web hook handler
1 parent f0e7bac commit 8ddddd7

4 files changed

Lines changed: 265 additions & 24 deletions

File tree

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
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\Services\CloudinaryPathService;
25+
use Visol\Cloudinary\Services\CloudinaryResourceService;
26+
use Visol\Cloudinary\Services\CloudinaryScanService;
27+
28+
class CloudinaryWebHookController extends ActionController
29+
{
30+
31+
protected const NOTIFICATION_TYPE_UPLOAD = 'upload';
32+
protected const NOTIFICATION_TYPE_RENAME = 'rename';
33+
34+
protected CloudinaryResourceService $cloudinaryResourceService;
35+
protected CloudinaryScanService $scanService;
36+
protected CloudinaryPathService $cloudinaryPathService;
37+
protected ProcessedFileRepository $processedFileRepository;
38+
protected ResourceStorage $storage;
39+
40+
protected function initializeAction()
41+
{
42+
$this->checkEnvironment();
43+
44+
/** @var ResourceFactory $resourceFactory */
45+
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
46+
47+
$storage = $resourceFactory->getStorageObject((int)$this->settings['storage']);
48+
$this->cloudinaryResourceService = GeneralUtility::makeInstance(
49+
CloudinaryResourceService::class,
50+
$storage,
51+
);
52+
53+
$this->scanService = GeneralUtility::makeInstance(
54+
CloudinaryScanService::class,
55+
$storage
56+
);
57+
58+
$this->cloudinaryPathService = GeneralUtility::makeInstance(
59+
CloudinaryPathService::class,
60+
$storage->getConfiguration()
61+
);
62+
63+
$this->storage = $storage;
64+
65+
$this->processedFileRepository = GeneralUtility::makeInstance(ProcessedFileRepository::class);
66+
}
67+
68+
public function processAction(): ResponseInterface
69+
{
70+
$parsedBody = (string)file_get_contents('php://input');
71+
$request = json_decode($parsedBody, true);
72+
self::getLogger()->debug($parsedBody);
73+
74+
if (!$this->isRequestUpload($request) || !$this->isRequestRename($request)) {
75+
return $this->sendResponse(['result' => 'ok', 'message' => 'Nothing to do...']);
76+
}
77+
78+
if ($this->isRequestUpload($request)) {
79+
$publicId = $request['public_id'];
80+
self::getLogger()->debug('File upload "overwritten" detected. Start flushing...');
81+
}
82+
83+
if ($this->isRequestRename($request)) {
84+
self::getLogger()->debug('File renamed detected. Start flushing...');
85+
86+
$publicId = $request['from_public_id'];
87+
$nextPublicId = $request['to_public_id'];
88+
}
89+
90+
if (empty($publicId)) {
91+
return $this->sendResponse([
92+
'result' => 'ko',
93+
'message' => 'Missing public id ',
94+
]);
95+
}
96+
97+
$cloudinaryResource = $this->cloudinaryResourceService->getResource($publicId);
98+
99+
// The resource does not exist, time to fetch
100+
if (!$cloudinaryResource) {
101+
$result = $this->scanService->scanOne($publicId);
102+
if (!$result) {
103+
return $this->sendResponse([
104+
'result' => 'ko',
105+
'message' => sprintf('I could not find a corresponding resource for public id %s', $publicId),
106+
]);
107+
}
108+
$cloudinaryResource = $this->cloudinaryResourceService->getResource($publicId);
109+
}
110+
111+
// #. retrieve the source file
112+
$fileIdentifier = $this->cloudinaryPathService->computeFileIdentifier($cloudinaryResource);
113+
try {
114+
$file = $this->storage->getFileByIdentifier($fileIdentifier);
115+
} catch (\Exception $e) {
116+
return $this->sendResponse([
117+
'result' => 'ko',
118+
'message' => sprintf('I could not find file with identifier "%s"', $fileIdentifier),
119+
]);
120+
}
121+
122+
// #. flush the process files
123+
$this->clearProcessedFiles($file);
124+
125+
// #. flush cache pages
126+
$this->clearCachePages($file);
127+
128+
return $this->sendResponse(['result' => 'ok']);
129+
}
130+
131+
protected function clearProcessedFiles(File $file): void
132+
{
133+
$processedFiles = $this->processedFileRepository->findAllByOriginalFile($file);
134+
135+
foreach ($processedFiles as $processedFile) {
136+
$processedFile->getStorage()->setEvaluatePermissions(false);
137+
$processedFile->delete();
138+
}
139+
}
140+
141+
protected function clearCachePages(File $file): void
142+
{
143+
$tags = [];
144+
foreach ($this->findPagesWithFileReferences($file) as $page) {
145+
$tags[] = 'pageId_' . $page['pid'];
146+
}
147+
148+
GeneralUtility::makeInstance(CacheManager::class)
149+
->flushCachesInGroupByTags('pages', $tags);
150+
}
151+
152+
protected function findPagesWithFileReferences(File $file): array
153+
{
154+
/** @var QueryBuilder $queryBuilder */
155+
$queryBuilder = $this->getQueryBuilder('sys_file_reference');
156+
return $queryBuilder
157+
->select('pid')
158+
->from('sys_file_reference')
159+
->groupBy('pid') // no support for distinct
160+
->andWhere(
161+
'pid > 0',
162+
'uid_local = ' . $file->getUid()
163+
)
164+
->execute()
165+
->fetchAllAssociative();
166+
}
167+
168+
protected function isRequestUpload(mixed $request): bool
169+
{
170+
return is_array($request) &&
171+
array_key_exists('notification_type', $request) &&
172+
array_key_exists('overwritten', $request) &&
173+
$request['notification_type'] === self::NOTIFICATION_TYPE_UPLOAD
174+
&& $request['overwritten'];
175+
}
176+
177+
protected function isRequestRename(mixed $request): bool
178+
{
179+
return is_array($request) &&
180+
array_key_exists('notification_type', $request) &&
181+
array_key_exists('overwritten', $request) &&
182+
$request['notification_type'] === self::NOTIFICATION_TYPE_RENAME;
183+
}
184+
185+
protected function sendResponse(array $data): ResponseInterface
186+
{
187+
return $this->jsonResponse(
188+
json_encode($data)
189+
);
190+
}
191+
192+
protected function checkEnvironment(): void
193+
{
194+
$storageUid = $this->settings['storage'] ?? 0;
195+
if ($storageUid <= 0) {
196+
throw new \RuntimeException('Check your configuration while calling the cloudinary web hook. I am missing a storage id', 1677583654);
197+
}
198+
}
199+
200+
protected function getQueryBuilder($tableName): QueryBuilder
201+
{
202+
/** @var ConnectionPool $connectionPool */
203+
$connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
204+
return $connectionPool->getQueryBuilderForTable($tableName);
205+
}
206+
207+
protected static function getLogger(): Logger
208+
{
209+
/** @var Logger $logger */
210+
static $logger = null;
211+
if ($logger === null) {
212+
$logger = GeneralUtility::makeInstance(LogManager::class)->getLogger(__CLASS__);
213+
}
214+
return $logger;
215+
}
216+
217+
}

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
---------------------

ext_localconf.php

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,20 @@
11
<?php
22

3+
use TYPO3\CMS\Core\Core\Environment;
4+
use TYPO3\CMS\Core\Log\LogLevel;
35
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
46
use TYPO3\CMS\Extbase\Utility\ExtensionUtility;
57
use Visol\Cloudinary\Backend\Form\Container\InlineCloudinaryControlContainer;
6-
use Visol\Cloudinary\Controller\CloudinaryScanController;
78
use TYPO3\CMS\Core\Resource\Driver\DriverRegistry;
89
use TYPO3\CMS\Core\Utility\GeneralUtility;
10+
use Visol\Cloudinary\Controller\CloudinaryWebHookController;
911
use Visol\Cloudinary\Driver\CloudinaryFastDriver;
10-
use TYPO3\CMS\Core\Log\LogLevel;
1112
use TYPO3\CMS\Core\Log\Writer\FileWriter;
1213
use TYPO3\CMS\Core\Cache\Frontend\VariableFrontend;
1314
use Visol\Cloudinary\Hook\FileUploadHook;
1415

1516
defined('TYPO3') || die('Access denied.');
16-
call_user_func(function () {
17-
ExtensionManagementUtility::addTypoScript(
18-
'cloudinary',
19-
'setup',
20-
'<INCLUDE_TYPOSCRIPT: source="FILE:EXT:cloudinary/Configuration/TypoScript/setup.typoscript">',
21-
);
17+
call_user_func(callback: function () {
2218

2319
// Override default class to add cloudinary button
2420
$GLOBALS['TYPO3_CONF_VARS']['SYS']['formEngine']['nodeRegistry'][1652423292] = [
@@ -29,13 +25,13 @@
2925

3026
ExtensionUtility::configurePlugin(
3127
\Cloudinary::class,
32-
'Cache',
28+
'WebHook',
3329
[
34-
CloudinaryScanController::class => 'scan',
30+
CloudinaryWebHookController::class => 'process',
3531
],
3632
// non-cacheable actions
3733
[
38-
CloudinaryScanController::class => 'scan',
34+
CloudinaryWebHookController::class => 'process',
3935
],
4036
);
4137

@@ -52,16 +48,32 @@
5248
$metaDataExtractorRegistry = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(\TYPO3\CMS\Core\Resource\Index\ExtractorRegistry::class);
5349
$metaDataExtractorRegistry->registerExtractionService(\Visol\Cloudinary\Services\Extractor\CloudinaryMetaDataExtractor::class);
5450

55-
$GLOBALS['TYPO3_CONF_VARS']['LOG']['Visol'][\Cloudinary::class]['Service']['writerConfiguration']
56-
= $GLOBALS['TYPO3_CONF_VARS']['LOG']['Visol'][\Cloudinary::class]['Cache']['writerConfiguration']
57-
= $GLOBALS['TYPO3_CONF_VARS']['LOG']['Visol'][\Cloudinary::class]['Driver']['writerConfiguration']
51+
// Log configuration for cloudinary web hook
52+
$GLOBALS['TYPO3_CONF_VARS']['LOG']['Visol']['Cloudinary']['Controller']['CloudinaryWebHookController']['writerConfiguration'] = [
53+
LogLevel::DEBUG => [
54+
FileWriter::class => [
55+
'logFile' => Environment::getVarPath() . '/log/cloudinary-web-hook.log'
56+
],
57+
],
58+
59+
// Configuration for WARNING severity, including all
60+
// levels with higher severity (ERROR, CRITICAL, EMERGENCY)
61+
LogLevel::WARNING => [
62+
\TYPO3\CMS\Core\Log\Writer\SyslogWriter::class => [],
63+
],
64+
];
65+
66+
// Log configuration for cloudinary driver
67+
$GLOBALS['TYPO3_CONF_VARS']['LOG']['Visol']['Cloudinary']['Service']['writerConfiguration']
68+
= $GLOBALS['TYPO3_CONF_VARS']['LOG']['Visol']['Cloudinary']['Cache']['writerConfiguration']
69+
= $GLOBALS['TYPO3_CONF_VARS']['LOG']['Visol']['Cloudinary']['Driver']['writerConfiguration']
5870
= [
5971
// configuration for WARNING severity, including all
6072
// levels with higher severity (ERROR, CRITICAL, EMERGENCY)
6173
LogLevel::INFO => [
6274
FileWriter::class => [
6375
// configuration for the writer
64-
'logFile' => 'typo3temp/var/logs/cloudinary.log',
76+
'logFile' => Environment::getVarPath() . '/log/cloudinary.log',
6577
],
6678
],
6779
];

0 commit comments

Comments
 (0)