Skip to content

Commit

Permalink
Merge pull request #11 from flownative/feature/webhooks
Browse files Browse the repository at this point in the history
Implement webhook handling to listen to changes in Canto
  • Loading branch information
kdambekalns committed Jan 19, 2022
2 parents 74e11d0 + 01c133e commit 0b62729
Show file tree
Hide file tree
Showing 6 changed files with 346 additions and 3 deletions.
88 changes: 88 additions & 0 deletions Classes/Middleware/WebhookMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);

namespace Flownative\Canto\Middleware;

/*
* This file is part of the Flownative.Canto package.
*
* (c) Karsten Dambekalns, Flownative GmbH - www.flownative.com
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/

use Flownative\Canto\Service\AssetUpdateService;
use Neos\Flow\Annotations as Flow;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

/**
* Canto webhook receiver middleware
*/
class WebhookMiddleware implements MiddlewareInterface
{
/**
* @Flow\InjectConfiguration(path="webhook.pathPrefix")
* @var string
*/
protected $webhookPathPrefix;

/**
* @Flow\InjectConfiguration(path="webhook.token")
* @var string
*/
protected $webhookToken;

/**
* @Flow\Inject
* @var ResponseFactoryInterface
*/
protected $responseFactory;

/**
* @Flow\Inject
* @var AssetUpdateService
*/
protected $assetUpdateService;

public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$requestedPath = $request->getUri()->getPath();
if (strpos($requestedPath, $this->webhookPathPrefix) !== 0) {
return $handler->handle($request);
}

try {
$payload = json_decode($request->getBody()->getContents(), true, 2, JSON_THROW_ON_ERROR);
if (!$this->validatePayload($payload)) {
return $this->responseFactory->createResponse(400, 'Invalid payload submitted');
}
} catch (\JsonException $e) {
return $this->responseFactory->createResponse(400, 'Invalid payload submitted, parse error');
}

if ($this->webhookToken && $this->webhookToken !== $payload['secure_token']) {
return $this->responseFactory->createResponse(403, 'Invalid token given');
}

$event = substr($requestedPath, strlen($this->webhookPathPrefix));
if ($this->assetUpdateService->handleEvent($event, $payload)) {
return $this->responseFactory->createResponse(204);
}

return $this->responseFactory->createResponse(500, 'Error during webhook processing');
}

private function validatePayload($metadata): bool
{
return is_array($metadata)
&& array_key_exists('secure_token', $metadata)
&& array_key_exists('scheme', $metadata)
&& array_key_exists('id', $metadata);
}
}
198 changes: 198 additions & 0 deletions Classes/Service/AssetUpdateService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);

namespace Flownative\Canto\Service;

/*
* This file is part of the Flownative.Canto package.
*
* (c) Karsten Dambekalns, Flownative GmbH - www.flownative.com
*
* This package is Open Source Software. For the full copyright and license
* information, please view the LICENSE file which was distributed with this
* source code.
*/

use Flownative\Canto\AssetSource\CantoAssetSource;
use Neos\Flow\Annotations as Flow;
use Neos\Flow\Log\ThrowableStorageInterface;
use Neos\Flow\Log\Utility\LogEnvironment;
use Neos\Flow\ResourceManagement\ResourceManager;
use Neos\Media\Domain\Model\AssetInterface;
use Neos\Media\Domain\Model\AssetSource\AssetSourceInterface;
use Neos\Media\Domain\Model\ImageVariant;
use Neos\Media\Domain\Repository\AssetRepository;
use Neos\Media\Domain\Repository\ImportedAssetRepository;
use Neos\Media\Domain\Service\AssetService;
use Neos\Media\Domain\Service\AssetSourceService;
use Psr\Log\LoggerInterface;

/**
* Canto asset update service for webhook handling
*/
final class AssetUpdateService
{
/**
* @Flow\Inject
* @var LoggerInterface
*/
protected $logger;

/**
* @Flow\Inject
* @var ThrowableStorageInterface
*/
protected $throwableStorage;

/**
* @Flow\Inject
* @var ImportedAssetRepository
*/
protected $importedAssetRepository;

/**
* @Flow\Inject
* @var AssetRepository
*/
protected $assetRepository;

/**
* @Flow\Inject
* @var AssetService
*/
protected $assetService;

/**
* @Flow\Inject
* @var ResourceManager
*/
protected $resourceManager;

/**
* @Flow\Inject
* @var AssetSourceService
*/
protected $assetSourceService;

public function handleEvent(string $event, array $payload): bool
{
switch ($event) {
case 'update':
return $this->handleAssetMetadataUpdated($payload);
case 'add':
return $this->handleNewAssetVersionAdded($payload);
default:
$this->logger->debug(sprintf('Unhandled event "%s" skipped', $event), LogEnvironment::fromMethodName(__METHOD__));
}

return false;
}

public function handleAssetMetadataUpdated(array $payload): bool
{
$identifier = $this->buildIdentifier($payload['scheme'], $payload['id']);

$importedAsset = $this->importedAssetRepository->findOneByAssetSourceIdentifierAndRemoteAssetIdentifier(CantoAssetSource::ASSET_SOURCE_IDENTIFIER, $identifier);
if ($importedAsset === null) {
$this->logger->debug(sprintf('Metadata update skipped on non-imported asset %s', $identifier), LogEnvironment::fromMethodName(__METHOD__));
return true;
}

try {
// Code like $localAsset->getResource()->setFilename($proxy->getFilename()) leads to a
// "Modifications are not allowed as soon as the PersistentResource has been published or persisted."
// error. Thus we need to replace the asset to get the new name into the system.
$this->replaceAsset($identifier);

// But code like this could be used to update other asset metadata:
// $assetProxy = $this->getAssetSource()->getAssetProxyRepository()->getAssetProxy($identifier);
// $localAssetIdentifier = $importedAsset->getLocalAssetIdentifier();
// $localAsset = $this->assetRepository->findByIdentifier($localAssetIdentifier);
// $localAsset->setTitle($assetProxy->getIptcProperty('Title'));
// $localAsset->setCaption($assetProxy->getIptcProperty('CaptionAbstract'));

return true;
} catch (\Exception $e) {
return false;
}
}

public function handleNewAssetVersionAdded(array $payload): bool
{
$identifier = $this->buildIdentifier($payload['scheme'], $payload['id']);

$importedAsset = $this->importedAssetRepository->findOneByAssetSourceIdentifierAndRemoteAssetIdentifier(CantoAssetSource::ASSET_SOURCE_IDENTIFIER, $identifier);
if ($importedAsset === null) {
$this->logger->debug(sprintf('Version update skipped on non-imported asset %s', $identifier), LogEnvironment::fromMethodName(__METHOD__));
return true;
}

try {
$this->flushProxyForAsset($identifier);
$this->replaceAsset($identifier);

return true;
} catch (\Exception $e) {
return false;
}
}

// TODO this "works" but used assets still have the same filename when used in frontend, so it seems incomplete
private function replaceAsset(string $identifier): void
{
$importedAsset = $this->importedAssetRepository->findOneByAssetSourceIdentifierAndRemoteAssetIdentifier(CantoAssetSource::ASSET_SOURCE_IDENTIFIER, $identifier);
$localAssetIdentifier = $importedAsset->getLocalAssetIdentifier();

/** @var AssetInterface $localAsset */
$localAsset = $this->assetRepository->findByIdentifier($localAssetIdentifier);
if ($localAsset instanceof ImageVariant) {
$this->logger->debug(sprintf('Did not replace resource on %s from %s, the local asset is an ImageVariant', $localAssetIdentifier, $identifier), LogEnvironment::fromMethodName(__METHOD__));
return;
}
// TODO do we need to delete the "old" resource? then we need to grab it here…
// $previousResource = $localAsset->getResource();

try {
$proxy = $this->getAssetSource()->getAssetProxyRepository()->getAssetProxy($identifier);
$assetResource = $this->resourceManager->importResource($proxy->getImportStream());
} catch (\Exception $e) {
$this->logger->debug(sprintf('Could not replace resource on %s from %s, exception: %s', $localAssetIdentifier, $identifier, $this->throwableStorage->logThrowable($e)), LogEnvironment::fromMethodName(__METHOD__));;
throw $e;
}
$assetResource->setFilename($proxy->getFilename());
$this->assetService->replaceAssetResource($localAsset, $assetResource);

// TODO … to delete it here!
// $this->resourceManager->deleteResource($previousResource);

$this->logger->debug(sprintf('Replaced resource on %s from %s', $localAssetIdentifier, $identifier), LogEnvironment::fromMethodName(__METHOD__));
}

private function buildIdentifier(string $scheme, string $identifier): string
{
return sprintf('%s-%s', $scheme, $identifier);
}

private function flushProxyForAsset(string $identifier): void
{
$assetProxyCache = $this->getAssetSource()->getAssetProxyCache();

if ($assetProxyCache->has($identifier)) {
$affectedEntriesCount = $assetProxyCache->remove($identifier);
$this->logger->debug(sprintf('Flushed asset proxy cache entry for %s, %u affected', $identifier, $affectedEntriesCount), LogEnvironment::fromMethodName(__METHOD__));
} else {
$this->logger->debug(sprintf('No asset proxy cache entry for %s found', $identifier), LogEnvironment::fromMethodName(__METHOD__));
}
}

/**
* @return AssetSourceInterface|CantoAssetSource
*/
private function getAssetSource(): AssetSourceInterface
{
/** @var CantoAssetSource $assetSource */
$assetSource = $this->assetSourceService->getAssetSources()[CantoAssetSource::ASSET_SOURCE_IDENTIFIER];
$assetSource->getCantoClient()->allowClientCredentialsAuthentication(true);
return $assetSource;
}
}
1 change: 1 addition & 0 deletions Classes/Service/CantoClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ public function allowClientCredentialsAuthentication(bool $allowed): void
private function authenticate(): void
{
$oAuthClient = new CantoOAuthClient($this->serviceName);

if ($this->securityContext->isInitialized()) {
$account = $this->securityContext->getAccount();
$accountAuthorization = $account ? $this->accountAuthorizationRepository->findOneByFlowAccountIdentifier($account->getAccountIdentifier()) : null;
Expand Down
2 changes: 1 addition & 1 deletion Configuration/Caches.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ Flownative_Canto_AssetProxy:
frontend: Neos\Cache\Frontend\StringFrontend
backend: Neos\Cache\Backend\FileBackend
backendOptions:
defaultLifetime: 60
defaultLifetime: 0
11 changes: 11 additions & 0 deletions Configuration/Settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,24 @@ Flownative:
mapping:
# map "Custom Fields" from Canto to Neos
customFields: []
webhook:
pathPrefix: '/flownative-canto/webhook/'
# A token that can be used to secure webhook invocations; used only if set
token: '%env:FLOWNATIVE_CANTO_WEBHOOK_TOKEN%'

Neos:
Flow:
mvc:
routes:
'Flownative.Canto':
position: 'start'

http:
middlewares:
'cantoWebhook':
position: 'before session'
middleware: 'Flownative\Canto\Middleware\WebhookMiddleware'

security:
authentication:
providers:
Expand Down
49 changes: 47 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ $ composer require flownative/neos-canto

#### Allow client credentials mode for API key

To be able to use the Canto connection from the command line, client credentials
mode must be enabled.
To be able to use the Canto connection from the command line or to use the webhook
feature described below, client credentials mode must be enabled.

1. In Canto go to Settings > Configuration Options > API > API Keys
2. Edit the API key you use for the Neos integration
Expand Down Expand Up @@ -133,6 +133,51 @@ Flownative:
**Note:** The asset collections and tags must be created manually on the Neos
side (for now.)

## Change notification from Canto

The package provides webhooks that can be used to notify of changes in Canto.

When assets in Canto are modified, those hooks trigger the needed update on the
Neos side.

- For *metadata updates* the changes are transferred to the imported asset, as
far as the metadata is used in Neos.
- If *new versions* are added, those are imported and replace the existing asset
in Neos.

### Enabling webhooks in Canto

1. In Canto go to Settings > Configuration Options > API > Webhooks
2. Generate some random string for use as "Secure Token"
3. Configure webhooks for "Update Metadata", "Add New Version"
1. Use the matching URL for each hook as shown below
2. Chose JSON as "Content Type"
3. Fill in the "Secure Token"
4. Click "Add"

| Event | Webhook URL (path) |
|-----------------|----------------------------------|
| Update Metadata | /flownative-canto/webhook/update |
| Add New Version | /flownative-canto/webhook/add |

Note: The webhook URL must be prefixed with the publicly accessible hostname of
your Neos instance, and HTTPS should be used to secure the secure token!

### Configure secure token in Neos

Set the "Secure Token" value using the environment variable

- `FLOWNATIVE_CANTO_WEBHOOK_TOKEN`

or directly in `Settings.yaml`

```yaml
Flownative:
Canto:
webhook:
token: 'some-random-string-of-your-choice'
```

### Cleaning up unused assets

Whenever a Canto asset is used in Neos, the media file will be copied
Expand Down

0 comments on commit 0b62729

Please sign in to comment.