From e6708736916aa862fc4a2f796efb26522d8d0682 Mon Sep 17 00:00:00 2001 From: Thomas Vantuycom <107400578+thomasvantuycom@users.noreply.github.com> Date: Sat, 20 Apr 2024 18:24:33 +0200 Subject: [PATCH] feat: add webhook notifications --- README.md | 6 +- src/controllers/NotificationsController.php | 332 ++++++++++++++++++++ 2 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 src/controllers/NotificationsController.php diff --git a/README.md b/README.md index 7def464..f2934c9 100644 --- a/README.md +++ b/README.md @@ -53,4 +53,8 @@ In addition, you can incorporate any of [Cloudinary's transformation options](ht ``` -Transformation options should be in camelCase, meaning `aspect_ratio` becomes `aspectRatio`, or `fetch_format` becomes `fetchFornat`. \ No newline at end of file +Transformation options should be in camelCase, meaning `aspect_ratio` becomes `aspectRatio`, or `fetch_format` becomes `fetchFornat`. + +## Webhook notifications + +To keep Craft aligned with changes made directly in Cloudinary, activate webhook notifications. Simply go to your [Cloudinary settings](https://console.cloudinary.com/settings/c-4547d495209fcc884b171f78858f04/webhooks) and add a new notification URL. Point it to the base URL of your website followed by `/actions/cloudinary/notifications/process?volume={VOLUME_ID}`. Remember to replace `{VOLUME_ID}` with the relevant asset volume ID. Enable the relevant notification types: `upload`, `delete`, `rename`, `create_folder`, and `delete_folder`. Keep in mind, this setup only functions in local development if your local domain is publicly accessible via a service like ngrok. Additionally, note that the webhook may struggle with a large volume of operations. If you frequently make extensive changes in the Cloudinary Console, consider re-indexing your asset volume instead. \ No newline at end of file diff --git a/src/controllers/NotificationsController.php b/src/controllers/NotificationsController.php new file mode 100644 index 0000000..594020b --- /dev/null +++ b/src/controllers/NotificationsController.php @@ -0,0 +1,332 @@ +requirePostRequest(); + + // Verify volume + $volumeId = $this->request->getRequiredQueryParam('volume'); + + $volume = Craft::$app->getVolumes()->getVolumeById($volumeId); + + if ($volume === null) { + throw new NotFoundHttpException('Volume not found'); + } + + $fs = $volume->getFs(); + + if (!$fs instanceof CloudinaryFs) { + throw new BadRequestHttpException('Invalid volume'); + } + + // Verify signature + Configuration::instance()->cloud->apiSecret = App::parseEnv($fs->apiSecret); + + $body = $this->request->getRawBody(); + $timestamp = $this->request->getHeaders()->get('X-Cld-Timestamp'); + $signature = $this->request->getHeaders()->get('X-Cld-Signature'); + + try { + if (SignatureVerifier::verifyNotificationSignature($body, $timestamp, $signature) === false) { + throw new BadRequestHttpException('Invalid signature'); + } + } catch (InvalidArgumentException $error) { + throw new BadRequestHttpException($error->getMessage(), 0, $error); + } + + // Process notification + $notificationType = $this->request->getRequiredBodyParam('notification_type'); + $baseFolder = App::parseEnv($fs->baseFolder); + + switch ($notificationType) { + case 'create_folder': + return $this->_processCreateFolder($volumeId, $baseFolder); + case 'delete_folder': + return $this->_processDeleteFolder($volumeId, $baseFolder); + case 'upload': + return $this->_processUpload($volumeId, $baseFolder); + case 'delete': + return $this->_processDelete($volumeId, $baseFolder); + case 'rename': + return $this->_processRename($volumeId, $baseFolder); + default: + return $this->asSuccess(); + } + } + + private function _processCreateFolder($volumeId, $baseFolder): Response + { + $name = $this->request->getRequiredBodyParam('folder_name'); + $path = $this->request->getRequiredBodyParam('folder_path'); + + if (!empty($baseFolder)) { + if (!str_starts_with($path, $baseFolder . '/')) { + return $this->asSuccess(); + } + + $path = substr($path, strlen($baseFolder) + 1); + } + + // Check if folder exists + $existingFolderQuery = (new Query()) + ->from([Table::VOLUMEFOLDERS]) + ->where([ + 'volumeId' => $volumeId, + 'path' => $path . '/', + ]); + + if ($existingFolderQuery->exists()) { + return $this->asSuccess(); + } + + // Get parent folder ID + $parentId = (new Query()) + ->select('id') + ->from(Table::VOLUMEFOLDERS) + ->where([ + 'volumeId' => $volumeId, + 'path' => ($name === $path) ? '' : dirname($path) . '/', + ]) + ->scalar(); + + // Store folder + $record = new VolumeFolderRecord([ + 'parentId' => $parentId, + 'volumeId' => $volumeId, + 'name' => $name, + 'path' => $path . '/', + ]); + $record->save(); + + return $this->asSuccess(); + } + + private function _processDeleteFolder($volumeId, $baseFolder): Response + { + $path = $this->request->getRequiredBodyParam('folder_path'); + + if (!empty($baseFolder)) { + if (!str_starts_with($path, $baseFolder . '/')) { + return $this->asSuccess(); + } + + $path = substr($path, strlen($baseFolder) + 1); + } + + // Delete folder + VolumeFolderRecord::deleteAll([ + 'volumeId' => $volumeId, + 'path' => $path . '/', + ]); + + return $this->asSuccess(); + } + + private function _processUpload($volumeId, $baseFolder): Response + { + $publicId = $this->request->getRequiredBodyParam('public_id'); + $folder = $this->request->getRequiredBodyParam('folder'); + $size = $this->request->getRequiredBodyParam('bytes'); + + if (!empty($baseFolder)) { + if ($folder !== $baseFolder && !str_starts_with($folder, $baseFolder . '/')) { + return $this->asSuccess(); + } + + $folder = substr($folder, strlen($baseFolder) + 1); + } + + // Get folder ID + $folderId = (new Query()) + ->select('id') + ->from(Table::VOLUMEFOLDERS) + ->where([ + 'volumeId' => $volumeId, + 'path' => $folder === '' ? '' : $folder . '/', + ]) + ->scalar(); + + // Check if asset exists + $filename = basename($publicId); + + $resourceType = $this->request->getRequiredBodyParam('resource_type'); + + if ($resourceType !== 'raw') { + $format = $this->request->getRequiredBodyParam('format'); + + $filename = $filename . '.' . $format; + } + + $existingAssetQuery = (new Query()) + ->from(['assets' => Table::ASSETS]) + ->innerJoin(['elements' => Table::ELEMENTS], '[[elements.id]] = [[assets.id]]') + ->where([ + 'assets.volumeId' => $volumeId, + 'assets.folderId' => $folderId, + 'assets.filename' => $filename, + 'elements.dateDeleted' => null, + ]); + + if ($existingAssetQuery->exists()) { + return $this->asSuccess(); + } + + // Store Asset + $kind = Assets::getFileKindByExtension($filename); + + $asset = new Asset([ + 'volumeId' => $volumeId, + 'folderId' => $folderId, + 'filename' => $filename, + 'kind' => $kind, + 'size' => $size, + ]); + + if ($kind === Asset::KIND_IMAGE) { + $asset->width = $this->request->getRequiredBodyParam('width'); + $asset->height = $this->request->getRequiredBodyParam('height'); + } + + $asset->setScenario(Asset::SCENARIO_INDEX); + Craft::$app->getElements()->saveElement($asset); + + return $this->asSuccess(); + } + + private function _processDelete($volumeId, $baseFolder): Response + { + $resources = $this->request->getRequiredBodyParam('resources'); + + foreach ($resources as $resource) { + $resourceType = $resource['resource_type']; + $publicId = $resource['public_id']; + $folder = $resource['folder']; + + if (!empty($baseFolder)) { + if ($folder !== $baseFolder && !str_starts_with($folder, $baseFolder . '/')) { + return $this->asSuccess(); + } + + $folder = substr($folder, strlen($baseFolder) + 1); + } + + $filename = basename($publicId); + $folderPath = $folder === '' ? '' : $folder . '/'; + + $assetQuery = Asset::find() + ->volumeId($volumeId) + ->folderPath($folderPath); + + if ($resourceType === 'raw') { + $assetQuery->filename($filename); + } else { + $assetQuery->filename("$filename.*"); + if ($resourceType === 'image') { + $assetQuery->kind('image'); + } else { + $assetQuery->kind(['video', 'audio']); + } + } + + $asset = $assetQuery->one(); + + if ($asset !== null) { + Craft::$app->getElements()->deleteElement($asset); + } + } + + return $this->asSuccess(); + } + + private function _processRename($volumeId, $baseFolder): Response + { + $resourceType = $this->request->getRequiredBodyParam('resource_type'); + $fromPublicId = $this->request->getRequiredBodyParam('from_public_id'); + $toPublicId = $this->request->getRequiredBodyParam('to_public_id'); + $folder = $this->request->getRequiredBodyParam('folder'); + + $fromFilename = basename($fromPublicId); + $fromFolder = dirname($fromPublicId); + $fromFolderPath = $fromFolder === '.' ? '' : $fromFolder . '/'; + $toFilename = basename($toPublicId); + $toFolderPath = $folder === '' ? '' : $folder . '/'; + + if (!empty($baseFolder)) { + if ($fromFolder !== $baseFolder && !str_starts_with($fromFolder, $baseFolder . '/')) { + return $this->asSuccess(); + } + + if ($folder !== $baseFolder && !str_starts_with($folder, $baseFolder . '/')) { + return $this->asSuccess(); + } + + $fromFolderPath = substr($fromFolderPath, strlen($baseFolder) + 1); + $toFolderPath = substr($toFolderPath, strlen($baseFolder) + 1); + } + + $assetQuery = Asset::find() + ->volumeId($volumeId) + ->folderPath($fromFolderPath); + + if ($resourceType === 'raw') { + $assetQuery->filename($fromFilename); + } else { + $assetQuery->filename("$fromFilename.*"); + if ($resourceType === 'image') { + $assetQuery->kind('image'); + } else { + $assetQuery->kind(['video', 'audio']); + } + } + + $asset = $assetQuery->one(); + + if ($asset !== null) { + if ($fromFolderPath !== $toFolderPath) { + $folderRecord = VolumeFolderRecord::findOne([ + 'volumeId' => $volumeId, + 'path' => $toFolderPath, + ]); + + $asset->folderId = $folderRecord->id; + } + + if ($fromFilename !== $toFilename) { + if ($resourceType === 'raw') { + $asset->filename = $toFilename; + } else { + $extension = pathinfo($asset->filename, PATHINFO_EXTENSION); + $asset->filename = "$toFilename.$extension"; + } + } + + Craft::$app->getElements()->saveElement($asset); + } + + return $this->asSuccess(); + } +}