diff --git a/Classes/AssetSource/CantoAssetProxy.php b/Classes/AssetSource/CantoAssetProxy.php new file mode 100644 index 0000000..20b1386 --- /dev/null +++ b/Classes/AssetSource/CantoAssetProxy.php @@ -0,0 +1,285 @@ +assetSource = $assetSource; + $assetProxy->identifier = $jsonObject->scheme . '|' . $jsonObject->id; + $assetProxy->label = $jsonObject->name; + $assetProxy->filename = Transliterator::urlize($jsonObject->name); + $assetProxy->lastModified = \DateTimeImmutable::createFromFormat('U', $jsonObject->time); + $assetProxy->fileSize = $jsonObject->size; + $assetProxy->mediaType = MediaTypes::getMediaTypeFromFilename($jsonObject->name); + $assetProxy->tags = $jsonObject->tag ?? []; + + $assetProxy->widthInPixels = $jsonObject->width ?? null; + $assetProxy->heightInPixels = $jsonObject->height ?? null; + + $assetProxy->thumbnailUri = new Uri($jsonObject->url->directUrlPreview); + $assetProxy->previewUri = new Uri($jsonObject->url->directUrlPreview); + $assetProxy->originalUri = new Uri($jsonObject->url->directUrlOriginal); + +# \Neos\Flow\var_dump($assetProxy->getMediaType()); + + return $assetProxy; + } + + /** + * @return AssetSourceInterface + */ + public function getAssetSource(): AssetSourceInterface + { + return $this->assetSource; + } + + /** + * @return string + */ + public function getIdentifier(): string + { + return $this->identifier; + } + + /** + * @return string + */ + public function getLabel(): string + { + return $this->label; + } + + /** + * @return string + */ + public function getFilename(): string + { + return $this->filename; + } + + /** + * @return \DateTimeInterface + */ + public function getLastModified(): \DateTimeInterface + { + return $this->lastModified; + } + + /** + * @return int + */ + public function getFileSize(): int + { + return $this->fileSize; + } + + /** + * @return string + */ + public function getMediaType(): string + { + return $this->mediaType; + } + + /** + * @param string $propertyName + * @return bool + */ + public function hasIptcProperty(string $propertyName): bool + { + return isset($this->iptcProperties[$propertyName]); + } + + /** + * @param string $propertyName + * @return string + */ + public function getIptcProperty(string $propertyName): string + { + return $this->iptcProperties[$propertyName] ?? ''; + } + + /** + * @return array + */ + public function getIptcProperties(): array + { + return $this->iptcProperties; + } + + /** + * @return int|null + */ + public function getWidthInPixels(): ?int + { + return $this->widthInPixels; + } + + /** + * @return int|null + */ + public function getHeightInPixels(): ?int + { + return $this->heightInPixels; + } + + /** + * @return UriInterface + */ + public function getThumbnailUri(): ?UriInterface + { + return $this->thumbnailUri; + } + + /** + * @return UriInterface + */ + public function getPreviewUri(): ?UriInterface + { + return $this->previewUri; + } + + /** + * @return resource + */ + public function getImportStream() + { + return fopen($this->assetSource->getCantoClient()->directUri($this->identifier), 'rb'); + } + + /** + * @return string + */ + public function getLocalAssetIdentifier(): ?string + { + $importedAsset = $this->importedAssetRepository->findOneByAssetSourceIdentifierAndRemoteAssetIdentifier($this->assetSource->getIdentifier(), $this->identifier); + return ($importedAsset instanceof ImportedAsset ? $importedAsset->getLocalAssetIdentifier() : null); + } + + /** + * @return array + */ + public function getTags(): array + { + return $this->tags; + } + + /** + * @return bool + */ + public function isImported(): bool + { + return true; + } +} diff --git a/Classes/AssetSource/CantoAssetProxyQuery.php b/Classes/AssetSource/CantoAssetProxyQuery.php new file mode 100644 index 0000000..a2813cc --- /dev/null +++ b/Classes/AssetSource/CantoAssetProxyQuery.php @@ -0,0 +1,246 @@ +assetSource = $assetSource; + } + + /** + * @param int $offset + */ + public function setOffset(int $offset): void + { + $this->offset = $offset; + } + + /** + * @return int + */ + public function getOffset(): int + { + return $this->offset; + } + + + /** + * @param int $limit + */ + public function setLimit(int $limit): void + { + $this->limit = $limit; + } + + /** + * @return int + */ + public function getLimit(): int + { + return $this->limit; + } + + /** + * @param string $searchTerm + */ + public function setSearchTerm(string $searchTerm) + { + $this->searchTerm = $searchTerm; + } + + /** + * @return string + */ + public function getSearchTerm(): string + { + return $this->searchTerm; + } + + /** + * @param string $assetTypeFilter + */ + public function setAssetTypeFilter(string $assetTypeFilter) + { + $this->assetTypeFilter = $assetTypeFilter; + } + + /** + * @return string + */ + public function getAssetTypeFilter(): string + { + return $this->assetTypeFilter; + } + + /** + * @return array + */ + public function getOrderings(): array + { + return $this->orderings; + } + + /** + * @param array $orderings + */ + public function setOrderings(array $orderings): void + { + $this->orderings = $orderings; + } + + /** + * @return string + */ + public function getParentFolderIdentifier(): string + { + return $this->parentFolderIdentifier; + } + + /** + * @param string $parentFolderIdentifier + */ + public function setParentFolderIdentifier(string $parentFolderIdentifier): void + { + $this->parentFolderIdentifier = $parentFolderIdentifier; + } + + /** + * @return AssetProxyQueryResultInterface + */ + public function execute(): AssetProxyQueryResultInterface + { + return new CantoAssetProxyQueryResult($this); + } + + /** + * @return int + */ + public function count(): int + { + $response = $this->sendSearchRequest(1, []); + $responseObject = \GuzzleHttp\json_decode($response->getBody()); + return $responseObject->found ?? 0; + } + + /** + * @return CantoAssetProxy[] + */ + public function getArrayResult(): array + { + $assetProxies = []; + $response = $this->sendSearchRequest($this->limit, $this->orderings); + $responseObject = \GuzzleHttp\json_decode($response->getBody()); + + foreach ($responseObject->results as $rawAsset) { + $assetProxies[] = CantoAssetProxy::fromJsonObject($rawAsset, $this->assetSource); + } + return $assetProxies; + } + + /** + * @param int $limit + * @param array $orderings + * @return Response + * @throws AuthenticationFailedException + * @throws ConnectionException + * @throws IdentityProviderException + */ + private function sendSearchRequest(int $limit, array $orderings): Response + { + $searchTerm = $this->searchTerm; + + switch ($this->assetTypeFilter) { + case 'Image': + $formatTypes = ['image']; + $fileTypes = []; + break; + case 'Video': + $formatTypes = ['video']; + $fileTypes = []; + break; + case 'Audio': + $formatTypes = ['audio']; + $fileTypes = []; + break; + case 'Document': + $formatTypes = ['document']; + $fileTypes = ['pdf']; + break; + case 'All': + default: + $formatTypes = ['image', 'video', 'audio', 'document']; + $fileTypes = []; + break; + } + + return $this->assetSource->getCantoClient()->search($searchTerm, $formatTypes, $fileTypes, $this->offset, $limit, $orderings); + } +} diff --git a/Classes/AssetSource/CantoAssetProxyQueryResult.php b/Classes/AssetSource/CantoAssetProxyQueryResult.php new file mode 100644 index 0000000..756da01 --- /dev/null +++ b/Classes/AssetSource/CantoAssetProxyQueryResult.php @@ -0,0 +1,154 @@ +query = $query; + } + + /** + * @return void + */ + private function initialize(): void + { + if ($this->assetProxies === null) { + $this->assetProxies = $this->query->getArrayResult(); + $this->assetProxiesIterator = new \ArrayIterator($this->assetProxies); + } + } + + /** + * @return AssetProxyQueryInterface + */ + public function getQuery(): AssetProxyQueryInterface + { + return clone $this->query; + } + + /** + * @return AssetProxyInterface|null + */ + public function getFirst(): ?AssetProxyInterface + { + $this->initialize(); + return reset($this->assetProxies); + } + + /** + * @return AssetProxyInterface[] + */ + public function toArray(): array + { + $this->initialize(); + return $this->assetProxies; + } + + public function current() + { + $this->initialize(); + return $this->assetProxiesIterator->current(); + } + + public function next() + { + $this->initialize(); + $this->assetProxiesIterator->next(); + } + + public function key() + { + $this->initialize(); + return $this->assetProxiesIterator->key(); + + } + + public function valid() + { + $this->initialize(); + return $this->assetProxiesIterator->valid(); + } + + public function rewind() + { + $this->initialize(); + $this->assetProxiesIterator->rewind(); + } + + public function offsetExists($offset) + { + $this->initialize(); + return $this->assetProxiesIterator->offsetExists($offset); + } + + public function offsetGet($offset) + { + $this->initialize(); + return $this->assetProxiesIterator->offsetGet($offset); + } + + public function offsetSet($offset, $value) + { + $this->initialize(); + $this->assetProxiesIterator->offsetSet($offset, $value); + } + + public function offsetUnset($offset) + { + } + + /** + * @return int + */ + public function count(): int + { + if ($this->numberOfAssetProxies === null) { + if (is_array($this->assetProxies)) { + return count($this->assetProxies); + } else { + return $this->query->count(); + } + } + } +} diff --git a/Classes/AssetSource/CantoAssetProxyRepository.php b/Classes/AssetSource/CantoAssetProxyRepository.php new file mode 100644 index 0000000..e65b52a --- /dev/null +++ b/Classes/AssetSource/CantoAssetProxyRepository.php @@ -0,0 +1,179 @@ +assetSource = $assetSource; + } + + /** + * @var string + */ + private $assetTypeFilter = 'All'; + + /** + * @var array + */ + private $orderings = []; + + /** + * @var VariableFrontend + */ + protected $assetProxyCache; + + /** + * @param string $identifier + * @return AssetProxyInterface + * @throws AssetNotFoundExceptionInterface + * @throws AssetSourceConnectionExceptionInterface + * @throws AuthenticationFailedException + * @throws AssetNotFoundException + * @throws Exception + */ + public function getAssetProxy(string $identifier): AssetProxyInterface + { + $client = $this->assetSource->getCantoClient(); + + $response = $client->getFile($identifier); + $responseObject = \GuzzleHttp\json_decode($response->getBody()); + + if (!$responseObject instanceof \stdClass) { + throw new AssetNotFoundException('Asset not found', 1526636260); + } + + return CantoAssetProxy::fromJsonObject($responseObject, $this->assetSource); + } + + /** + * @param AssetTypeFilter $assetType + */ + public function filterByType(AssetTypeFilter $assetType = null): void + { + $this->assetTypeFilter = (string)$assetType ?: 'All'; + } + + /** + * @return AssetProxyQueryResultInterface + */ + public function findAll(): AssetProxyQueryResultInterface + { + $query = new CantoAssetProxyQuery($this->assetSource); + $query->setAssetTypeFilter($this->assetTypeFilter); + $query->setOrderings($this->orderings); + return new CantoAssetProxyQueryResult($query); + } + + /** + * @param string $searchTerm + * @return AssetProxyQueryResultInterface + */ + public function findBySearchTerm(string $searchTerm): AssetProxyQueryResultInterface + { + $query = new CantoAssetProxyQuery($this->assetSource); + $query->setSearchTerm($searchTerm); + $query->setAssetTypeFilter($this->assetTypeFilter); + $query->setOrderings($this->orderings); + return new CantoAssetProxyQueryResult($query); + } + + /** + * @param Tag $tag + * @return AssetProxyQueryResultInterface + */ + public function findByTag(Tag $tag): AssetProxyQueryResultInterface + { + $query = new CantoAssetProxyQuery($this->assetSource); + $query->setSearchTerm($tag->getLabel()); + $query->setAssetTypeFilter($this->assetTypeFilter); + $query->setOrderings($this->orderings); + return new CantoAssetProxyQueryResult($query); + } + + /** + * @return AssetProxyQueryResultInterface + */ + public function findUntagged(): AssetProxyQueryResultInterface + { + $query = new CantoAssetProxyQuery($this->assetSource); + $query->setAssetTypeFilter($this->assetTypeFilter); + $query->setOrderings($this->orderings); + return new CantoAssetProxyQueryResult($query); + } + + /** + * @return int + */ + public function countAll(): int + { + $query = new CantoAssetProxyQuery($this->assetSource); + return $query->count(); + } + + /** + * Sets the property names to order results by. Expected like this: + * array( + * 'filename' => SupportsSorting::ORDER_ASCENDING, + * 'lastModified' => SupportsSorting::ORDER_DESCENDING + * ) + * + * @param array $orderings The property names to order by by default + * @return void + * @api + */ + public function orderBy(array $orderings): void + { + $this->orderings = $orderings; + } + + /** + * @return StringFrontend + */ + public function getAssetProxyCache(): StringFrontend + { + if ($this->assetProxyCache instanceof DependencyProxy) { + $this->assetProxyCache->_activateDependency(); + } + return $this->assetProxyCache; + } +} diff --git a/Classes/AssetSource/CantoAssetSource.php b/Classes/AssetSource/CantoAssetSource.php new file mode 100644 index 0000000..3a16d11 --- /dev/null +++ b/Classes/AssetSource/CantoAssetSource.php @@ -0,0 +1,245 @@ +assetSourceIdentifier = $assetSourceIdentifier; + $this->assetSourceOptions = $assetSourceOptions; + + foreach ($assetSourceOptions as $optionName => $optionValue) { + switch ($optionName) { + case 'apiBaseUri': + $uri = new Uri($optionValue); + $this->apiBaseUri = $uri->__toString(); + break; + case 'oAuthBaseUri': + $uri = new Uri($optionValue); + $this->oAuthBaseUri = $uri->__toString(); + break; + case 'appId': + if (!is_string($optionValue) || empty($optionValue)) { + throw new \InvalidArgumentException(sprintf('Invalid app id specified for Canto asset source %s', $assetSourceIdentifier), 1607085646); + } + $this->appId = $optionValue; + break; + case 'appSecret': + if (!is_string($optionValue) || empty($optionValue)) { + throw new \InvalidArgumentException(sprintf('Invalid app secret specified for Canto asset source %s', $assetSourceIdentifier), 1607085604); + } + $this->appSecret = $optionValue; + break; + case 'mediaTypes': + if (!is_array($optionValue)) { + throw new \InvalidArgumentException(sprintf('Invalid media types specified for Canto asset source %s', $assetSourceIdentifier), 1542809628); + } + foreach ($optionValue as $mediaType => $mediaTypeOptions) { + if (MediaTypes::getFilenameExtensionsFromMediaType($mediaType) === []) { + throw new \InvalidArgumentException(sprintf('Unknown media type "%s" specified for Canto asset source %s', $mediaType, $assetSourceIdentifier), 1542809775); + } + } + break; + case 'iconPath': + case 'description': + $this->$optionName = (string)$optionValue; + break; + default: + throw new \InvalidArgumentException(sprintf('Unknown asset source option "%s" specified for Canto asset source "%s". Please check your settings.', $optionName, $assetSourceIdentifier), 1525790910); + } + } + } + + /** + * @param string $assetSourceIdentifier + * @param array $assetSourceOptions + * @return AssetSourceInterface + */ + public static function createFromConfiguration(string $assetSourceIdentifier, array $assetSourceOptions): AssetSourceInterface + { + return new static($assetSourceIdentifier, $assetSourceOptions); + } + + /** + * @return string + */ + public function getIdentifier(): string + { + return $this->assetSourceIdentifier; + } + + /** + * @return string + */ + public function getLabel(): string + { + return 'Canto'; + } + + /** + * @return AssetProxyRepositoryInterface + */ + public function getAssetProxyRepository(): AssetProxyRepositoryInterface + { + if ($this->assetProxyRepository === null) { + $this->assetProxyRepository = new CantoAssetProxyRepository($this); + } + + return $this->assetProxyRepository; + } + + /** + * @return bool + */ + public function isReadOnly(): bool + { + return true; + } + + /** + * @return array + */ + public function getAssetSourceOptions(): array + { + return $this->assetSourceOptions; + } + + /** + * @return bool + */ + public function isAutoTaggingEnabled(): bool + { + return $this->autoTaggingEnable; + } + + /** + * @return string + */ + public function getAutoTaggingInUseTag(): string + { + return $this->autoTaggingInUseTag; + } + + /** + * @return string + */ + public function getIconUri(): string + { + return $this->resourceManager->getPublicPackageResourceUriByPath($this->iconPath); + } + + /** + * @return string + */ + public function getDescription(): string + { + return $this->description; + } + + /** + * @return CantoClient + * @throws AuthenticationFailedException + * @throws IdentityProviderException + */ + public function getCantoClient(): CantoClient + { + if ($this->cantoClient === null) { + $this->cantoClient = new CantoClient( + $this->apiBaseUri, + $this->oAuthBaseUri, + $this->appId, + $this->appSecret + ); + + $this->cantoClient->authenticate(); + } + return $this->cantoClient; + } +} diff --git a/Classes/Command/CantoCommandController.php b/Classes/Command/CantoCommandController.php new file mode 100644 index 0000000..c55b936 --- /dev/null +++ b/Classes/Command/CantoCommandController.php @@ -0,0 +1,115 @@ +assetRepository->findAllIterator(); + + !$quiet && $this->outputLine('Tagging used assets of asset source "%s" via Canto API:', [$assetSourceIdentifier]); + + try { + $cantoAssetSource = new CantoAssetSource($assetSourceIdentifier, $this->assetSourcesConfiguration[$assetSourceIdentifier]['assetSourceOptions']); + $cantoClient = $cantoAssetSource->getCantoClient(); + } catch (MissingClientSecretException $e) { + $this->outputLine('Authentication error: Missing client secret'); + exit(1); + } catch (AuthenticationFailedException $e) { + $this->outputLine('Authentication error: %s', [$e->getMessage()]); + exit(1); + } + + if (!$cantoAssetSource->isAutoTaggingEnabled()) { + $this->outputLine('Auto-tagging is disabled'); + exit(1); + } + + $assetProxyRepository = $cantoAssetSource->getAssetProxyRepository(); + assert($assetProxyRepository instanceof CantoAssetProxyRepository); + $assetProxyRepository->getAssetProxyCache()->flush(); + + foreach ($this->assetRepository->iterate($iterator) as $asset) { + if (!$asset instanceof Asset) { + continue; + } + if (!$asset instanceof AssetSourceAwareInterface) { + continue; + } + if ($asset->getAssetSourceIdentifier() !== $assetSourceIdentifier) { + continue; + } + + try { + $assetProxy = $asset->getAssetProxy(); + } catch (AccessToAssetDeniedException $exception) { + $this->outputLine(' error %s', [$exception->getMessage()]); + continue; + } + + if (!$assetProxy instanceof CantoAssetProxy) { + $this->outputLine(' error Asset "%s" (%s) could not be accessed via Canto-API', [$asset->getLabel(), $asset->getIdentifier()]); + continue; + } + + $currentTags = $assetProxy->getTags(); + sort($currentTags); + if ($asset->getUsageCount() > 0) { + $newTags = array_unique(array_merge($currentTags, [$cantoAssetSource->getAutoTaggingInUseTag()])); + sort($newTags); + + if ($currentTags !== $newTags) { + $cantoClient->updateFile($assetProxy->getIdentifier(), ['keywords' => implode(',', $newTags)]); + $this->outputLine(' tagged %s %s (%s)', [$asset->getLabel(), $assetProxy->getIdentifier(), $asset->getUsageCount()]); + } else { + $this->outputLine(' (tagged) %s %s (%s)', [$asset->getLabel(), $assetProxy->getIdentifier(), $asset->getUsageCount()]); + } + } else { + $newTags = array_flip($currentTags); + unset($newTags[$cantoAssetSource->getAutoTaggingInUseTag()]); + $newTags = array_flip($newTags); + sort($newTags); + + if ($currentTags !== $newTags) { + $cantoClient->updateFile($assetProxy->getIdentifier(), ['keywords' => implode(',', $newTags)]); + $this->outputLine(' removed %s', [$asset->getLabel(), $asset->getUsageCount()]); + } else { + $this->outputLine(' (removed) %s', [$asset->getLabel(), $asset->getUsageCount()]); + } + } + } + } +} diff --git a/Classes/Controller/CantoController.php b/Classes/Controller/CantoController.php new file mode 100644 index 0000000..d6870d7 --- /dev/null +++ b/Classes/Controller/CantoController.php @@ -0,0 +1,49 @@ +assetSourcesConfiguration['flownative-canto']['assetSourceOptions']['apiBaseUri']; + $this->view->assign('apiBaseUri', $apiBaseUri); + + try { + $assetSource = new CantoAssetSource('flownative-canto', $this->assetSourcesConfiguration['flownative-canto']['assetSourceOptions']); + $client = $assetSource->getCantoClient(); + $this->view->assign('user', $client->user()); + + $this->view->assign('tree', $client->tree()); + + $this->view->assign('connectionSucceeded', true); + } catch (AuthenticationFailedException $e) { + $this->view->assign('authenticationError', $e->getMessage()); + } + } +} diff --git a/Classes/Domain/Model/ClientSecret.php b/Classes/Domain/Model/ClientSecret.php new file mode 100644 index 0000000..ca66cdd --- /dev/null +++ b/Classes/Domain/Model/ClientSecret.php @@ -0,0 +1,88 @@ +flowAccountIdentifier; + } + + /** + * @param string $flowAccountIdentifier + */ + public function setFlowAccountIdentifier(string $flowAccountIdentifier): void + { + $this->flowAccountIdentifier = $flowAccountIdentifier; + } + + /** + * @return string + */ + public function getRefreshToken(): string + { + return $this->refreshToken; + } + + /** + * @param string $refreshToken + */ + public function setRefreshToken(string $refreshToken): void + { + $this->refreshToken = $refreshToken; + } + + /** + * @return string + */ + public function getAccessToken(): ?string + { + return $this->accessToken; + } + + /** + * @param string|null $accessToken + */ + public function setAccessToken($accessToken): void + { + $this->accessToken = $accessToken; + } +} diff --git a/Classes/Domain/Repository/ClientSecretRepository.php b/Classes/Domain/Repository/ClientSecretRepository.php new file mode 100644 index 0000000..a3ba9d6 --- /dev/null +++ b/Classes/Domain/Repository/ClientSecretRepository.php @@ -0,0 +1,31 @@ +__call('findOneByFlowAccountIdentifier', [$accountIdentifier]); + } +} diff --git a/Classes/Exception/AccessToAssetDeniedException.php b/Classes/Exception/AccessToAssetDeniedException.php new file mode 100644 index 0000000..2be51df --- /dev/null +++ b/Classes/Exception/AccessToAssetDeniedException.php @@ -0,0 +1,19 @@ +apiBaseUri = $apiBaseUri; + $this->oAuthBaseUri = $oauthBaseUri; + $this->appId = $appId; + $this->appSecret = $appSecret; + + $this->guzzleClient = new Client(); + $this->imageOptions = [ + (object)[ + 'width' => 400, + 'height' => 400, + 'quality' => 90 + ], + (object)[ + 'width' => 1500, + 'height' => 1500, + 'quality' => 90 + ], + (object)[ + 'sizeMax' => 1920, + 'quality' => 90 + ] + ]; + } + + /** + * @throws AuthenticationFailedException + * @throws IdentityProviderException + */ + public function authenticate(): void + { + $this->oAuthClient = new CantoOAuthClient('canto'); + $this->oAuthClient->setBaseUri($this->oAuthBaseUri); + + $authorizationId = Authorization::generateAuthorizationIdForClientCredentialsGrant('canto', $this->appId, $this->appSecret, 'admin'); + $this->authorization = $this->oAuthClient->getAuthorization($authorizationId); + + if ($this->authorization === null) { + $this->oAuthClient->requestAccessToken('canto', $this->appId, $this->appSecret, 'admin'); + $this->authorization = $this->oAuthClient->getAuthorization($authorizationId); + } + + if ($this->authorization === null) { + throw new AuthenticationFailedException('Authentication failed: ' . ($result->help ?? 'Unknown cause'), 1607086346); + } + } + + /** + * @param string $assetProxyId + * @return ResponseInterface + * @throws OAuthClientException + */ + public function getFile(string $assetProxyId): ResponseInterface + { + [$scheme, $id] = explode('|', $assetProxyId); + return $this->sendAuthenticatedRequest( + $this->authorization, + $scheme . '/' . $id, + 'GET', + [] + ); + } + + /** + * @param string $id + * @param array $metadata + * @return ResponseInterface + */ + public function updateFile(string $id, array $metadata): ResponseInterface + { + } + + /** + * @param string $keyword + * @param array $formatTypes + * @param array $fileTypes + * @param int $offset + * @param int $limit + * @param array $orderings + * @return ResponseInterface + * @throws OAuthClientException + */ + public function search(string $keyword, array $formatTypes, array $fileTypes, int $offset = 0, int $limit = 50, $orderings = []): ResponseInterface + { + $pathAndQuery = 'search?keyword=' . urlencode($keyword); + + $pathAndQuery .= '&limit=' . urlencode($limit); + $pathAndQuery .= '&start=' . urlencode($offset); + + if ($formatTypes !== []) { + $pathAndQuery .= '&scheme=' . urlencode(implode('|', $formatTypes)); + } + + if (isset($orderings['filename'])) { + $pathAndQuery .= '&sortBy=name'; + $pathAndQuery .= '&sortDirection=' . (($orderings['filename'] === SupportsSortingInterface::ORDER_DESCENDING) ? 'descending' : 'ascending'); + } + + if (isset($orderings['lastModified'])) { + $pathAndQuery .= '&sortBy=time'; + $pathAndQuery .= '&sortDirection=' . (($orderings['lastModified'] === SupportsSortingInterface::ORDER_DESCENDING) ? 'descending' : 'ascending'); + } + + return $this->sendAuthenticatedRequest( + $this->authorization, + $pathAndQuery, + 'GET', + [] + ); + } + + /** + * @return array + * @throws OAuthClientException + */ + public function user(): array + { + $response = $this->sendAuthenticatedRequest($this->authorization, 'user'); + if ($response->getStatusCode() === 200) { + return \GuzzleHttp\json_decode($response->getBody()->getContents(), true); + } + return []; + } + + /** + * @return array + * @throws OAuthClientException + */ + public function tree(): array + { + $response = $this->sendAuthenticatedRequest($this->authorization, 'tree'); + if ($response->getStatusCode() === 200) { + return \GuzzleHttp\json_decode($response->getBody()->getContents(), true); + } + return []; + } + + /** + * @param string $assetProxyId + * @return Uri|null + * @throws OAuthClientException + */ + public function directUri(string $assetProxyId): ?Uri + { + [$scheme, $id] = explode('|', $assetProxyId); + + if ($this->httpClient === null) { + $this->httpClient = new Client(['allow_redirects' => true]); + } + + $accessToken = $this->authorization->getAccessToken(); + + $apiBinaryBaseUri = str_replace('/api/', '/api_binary/', $this->apiBaseUri); + + $request = new Request( + 'GET', + $apiBinaryBaseUri . '/' . $scheme . '/' . $id . '/directuri', + [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $accessToken + ] + ); + + $response = $this->httpClient->send($request); + + if ($response->getStatusCode() === 200) { + return new Uri($response->getBody()->getContents()); + } + return null; + } + + /** + * @return string + */ + public function getAccessToken(): ?AccessToken + { + return $this->authorization->getAccessToken(); + } + + + /** + * Returns a prepared request to an OAuth 2.0 service provider using Bearer token authentication + * + * @param Authorization $authorization + * @param string $uriPathAndQuery A relative URI of the web server, prepended by the base URI + * @param string $method The HTTP method, for example "GET" or "POST" + * @param array $bodyFields Associative array of body fields to send (optional) + * @return RequestInterface + * @throws OAuthClientException + */ + public function getAuthenticatedRequest(Authorization $authorization, string $uriPathAndQuery, string $method = 'GET', array $bodyFields = []): RequestInterface + { + $accessToken = $authorization->getAccessToken(); + if ($accessToken === null) { + throw new OAuthClientException(sprintf('Canto: Failed getting an authenticated request for client ID "%s" because the authorization contained no access token', $authorization->getClientId()), 1607087086); + } + + return new Request( + $method, + $this->apiBaseUri . '/' . $uriPathAndQuery, + [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer ' . $authorization->getAccessToken() + ], + ($bodyFields !== [] ? \GuzzleHttp\json_encode($bodyFields) : '') + ); + } + + /** + * Sends an HTTP request to an OAuth 2.0 service provider using Bearer token authentication + * + * @param Authorization $authorization + * @param string $uriPathAndQuery + * @param string $method + * @param array $bodyFields + * @return Response + * @throws OAuthClientException + */ + public function sendAuthenticatedRequest(Authorization $authorization, string $uriPathAndQuery, string $method = 'GET', array $bodyFields = []): Response + { + if ($this->httpClient === null) { + $this->httpClient = new Client(['allow_redirects' => true]); + } + return $this->httpClient->send($this->getAuthenticatedRequest($authorization, $uriPathAndQuery, $method, $bodyFields)); + } + +} diff --git a/Classes/Service/CantoOAuthClient.php b/Classes/Service/CantoOAuthClient.php new file mode 100644 index 0000000..64bf735 --- /dev/null +++ b/Classes/Service/CantoOAuthClient.php @@ -0,0 +1,101 @@ +baseUri = $baseUri; + } + + /** + * For example: https://oauth.canto.global/oauth/api/oauth2 + * + * @return string + */ + public function getBaseUri(): string + { + return $this->baseUri; + } + + /** + * @return string + */ + public function getAccessTokenUri(): string + { + return trim($this->getBaseUri(), '/') . '/token'; + } + + /** + * @return string + */ + public function getAuthorizeTokenUri(): string + { + return trim($this->getBaseUri(), '/') . '/token/authorize'; + } + + public function getResourceOwnerUri(): string + { + return trim($this->getBaseUri(), '/') . '/token/resource'; + } + + public function getClientId(): string + { + // TODO: Implement getClientId() method. + } + + /** + * @param string $clientId + * @param string $clientSecret + * @return GenericProvider + */ + protected function createOAuthProvider(string $clientId, string $clientSecret): GenericProvider + { + return new CantoOAuthProvider([ + 'clientId' => $clientId, + 'clientSecret' => $clientSecret, + 'redirectUri' => $this->renderFinishAuthorizationUri(), + 'urlAuthorize' => $this->getAuthorizeTokenUri(), + 'urlAccessToken' => $this->getAccessTokenUri(), + 'urlResourceOwnerDetails' => $this->getResourceOwnerUri(), + ], [ + 'requestFactory' => $this->getRequestFactory() + ]); + } +} diff --git a/Classes/Service/CantoOAuthProvider.php b/Classes/Service/CantoOAuthProvider.php new file mode 100644 index 0000000..fe07c06 --- /dev/null +++ b/Classes/Service/CantoOAuthProvider.php @@ -0,0 +1,64 @@ +verifyGrant($grant); + + $params = [ + 'app_id' => $this->clientId, + 'app_secret' => $this->clientSecret, + ]; + + $params = $grant->prepareRequestParameters($params, $options); + $request = $this->getAccessTokenRequest($params); + + $response = $this->getParsedResponse($request); + if (false === is_array($response)) { + throw new UnexpectedValueException( + 'Invalid response received from Authorization Server. Expected JSON.' + ); + } + + // The Canto OAuth server uses camelCase instead of snake_case + $normalizedResponse = [ + 'access_token' => $response['accessToken'] ?? '', + 'expires_in' => $response['expiresIn'] ?? '', + 'token_type' => $response['tokenType'] ?? '', + 'refresh_token' => $response['refreshToken'] ?? null + ]; + + $preparedTokenResponse = $this->prepareAccessTokenResponse($normalizedResponse); + return $this->createAccessToken($preparedTokenResponse, $grant); + } +} diff --git a/Configuration/Caches.yaml b/Configuration/Caches.yaml new file mode 100644 index 0000000..73a74fc --- /dev/null +++ b/Configuration/Caches.yaml @@ -0,0 +1,5 @@ +Flownative_Canto_AssetProxy: + frontend: Neos\Cache\Frontend\StringFrontend + backend: Neos\Cache\Backend\FileBackend + backendOptions: + defaultLifetime: 60 diff --git a/Configuration/Objects.yaml b/Configuration/Objects.yaml new file mode 100644 index 0000000..34b81b6 --- /dev/null +++ b/Configuration/Objects.yaml @@ -0,0 +1,19 @@ +Flownative\Canto\AssetSource\CantoAssetProxyRepository: + properties: + assetProxyCache: + object: + factoryObjectName: Neos\Flow\Cache\CacheManager + factoryMethodName: getCache + arguments: + 1: + value: Flownative_Canto_AssetProxy + +Flownative\Canto\Service\CantoOAuthClient: + properties: + stateCache: + object: + factoryObjectName: Neos\Flow\Cache\CacheManager + factoryMethodName: getCache + arguments: + 1: + value: Flownative_OAuth2_Client_State diff --git a/Configuration/Policy.yaml b/Configuration/Policy.yaml new file mode 100644 index 0000000..6263623 --- /dev/null +++ b/Configuration/Policy.yaml @@ -0,0 +1,19 @@ +# +# Security policy for the Flownative Canto package +# + +privilegeTargets: + + 'Neos\Flow\Security\Authorization\Privilege\Method\MethodPrivilege': + + + 'Flownative.Canto:ManageConnection': + matcher: 'method(Flownative\Canto\Controller\CantoController->(index|updateRefreshToken)Action())' + +roles: + + 'Neos.Neos:Administrator': + privileges: + - + privilegeTarget: 'Flownative.Canto:ManageConnection' + permission: GRANT diff --git a/Configuration/Settings.yaml b/Configuration/Settings.yaml new file mode 100644 index 0000000..5e29d59 --- /dev/null +++ b/Configuration/Settings.yaml @@ -0,0 +1,29 @@ +Neos: + Flow: + mvc: + routes: + 'Flownative.Canto': + position: 'after Neos.Neos' + + Media: + assetSources: + 'flownative-canto': + assetSource: 'Flownative\Canto\AssetSource\CantoAssetSource' + assetSourceOptions: + iconPath: 'resource://Flownative.Canto/Public/Icons/Canto-Logo-White.svg' + description: 'Assets in Canto Digital Asset Management' + appId: '%env:FLOWNATIVE_CANTO_OAUTH_APP_ID%' + appSecret: '%env:FLOWNATIVE_CANTO_OAUTH_APP_SECRET%' + apiBaseUri: '%env:FLOWNATIVE_CANTO_API_BASE_URI%' + oAuthBaseUri: '%env:FLOWNATIVE_CANTO_OAUTH_BASE_URI%' + + Neos: + modules: + management: + submodules: + canto: + controller: \Flownative\Canto\Controller\CantoController + label: 'Canto' + description: 'Flownative.Canto:Main:moduleDescription' + icon: 'icon-photo' + privilegeTarget: 'Flownative.Canto:ManageConnection' diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5791d1f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 Robert Lemke / Flownative GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..33572d9 --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +[![MIT license](http://img.shields.io/badge/license-MIT-brightgreen.svg)](http://opensource.org/licenses/MIT) +[![Packagist](https://img.shields.io/packagist/v/flownative/neos-canto.svg)](https://packagist.org/packages/flownative/neos-canto) + +# Canto adaptor for Neos 5.x + +This [Flow](https://flow.neos.io) package allows you to use assets (ie. +pictures and other documents) stored in [Canto](https://www.canto.com/) +in your Neos website as if these assets were native Neos assets. + +## About Canto +Canto offers a extensive solutions for digital asset management. The +software makes working with pictures, graphics and video files easier. + +## Key Features + +- authentication setup via own backend module +- seamless integration into the Neos media browser +- automatic import and clean up of media assets from Canto + +## Installation + +The Canto connector is installed as a regular Flow package via Composer. +For your existing project, simply include `flownative/neos-canto` into +the dependencies of your Flow or Neos distribution: + +```bash +$ composer require flownative/neos-canto:0 +``` + +## Enabling Canto API access + +tbd. + +## Additional configuration options + +tbd. + +## Cleaning up unused assets + +Whenever a Canto asset is used in Neos, the media file will be copied +automatically to the internal Neos asset storage. As long as this media +is used somewhere on the website, Neos will flag this asset as being in +use. When an asset is not used anymore, the binary data and the +corresponding metadata can be removed from the internal storage. While +this does not happen automatically, it can be easily automated by a +recurring task, such as a cron-job. + +In order to clean up unused assets, simply run the following command as +often as you like: + +```bash +./flow media:removeunused --asset-source flownative-canto +``` + +If you'd rather like to invoke this command through a cron-job, you can +add two additional flags which make this command non-interactive: + +```bash +./flow media:removeunused --quiet --assume-yes --asset-source flownative-canto +``` + +## Credits and license + +This plugin was sponsored by [Paessler](https://www.paessler.com/) and +its initial version was developed by Robert Lemke of +[Flownative](https://www.flownative.com). + +See LICENSE for license details. diff --git a/Resources/Private/Templates/Canto/Index.html b/Resources/Private/Templates/Canto/Index.html new file mode 100644 index 0000000..c3611ed --- /dev/null +++ b/Resources/Private/Templates/Canto/Index.html @@ -0,0 +1,50 @@ +{namespace neos=Neos\Neos\ViewHelpers} +
+

{neos:backend.translate(id: 'cantoConnectionSettings', source: 'Main', package: 'Flownative.Canto')}

+
+ +
+
+
+
+ +
+ +
+
+ +
+
+ + + + +
{user.firstName} {user.lastName}{user.email}
+
+
+
+
+ +

{authenticationError}

+
+
+
+
+
+
    + +
  • + {result.id} {result.name} + +
  • + {child.id} {child.name} +
  • +
    + + +
+
+
+
+ +
diff --git a/Resources/Private/Translations/en/Main.xlf b/Resources/Private/Translations/en/Main.xlf new file mode 100644 index 0000000..2765f94 --- /dev/null +++ b/Resources/Private/Translations/en/Main.xlf @@ -0,0 +1,13 @@ + + + + + + This module allows you to connect with Canto, a cloud-based media asset management service, in order to use remote assets as if they were stored locally in your Neos site. + + + Canto Connection Settings + + + + diff --git a/Resources/Public/Icons/Canto-FullLogo-Orange.svg b/Resources/Public/Icons/Canto-FullLogo-Orange.svg new file mode 100644 index 0000000..72711ff --- /dev/null +++ b/Resources/Public/Icons/Canto-FullLogo-Orange.svg @@ -0,0 +1 @@ + diff --git a/Resources/Public/Icons/Canto-FullLogo-White.svg b/Resources/Public/Icons/Canto-FullLogo-White.svg new file mode 100644 index 0000000..443529d --- /dev/null +++ b/Resources/Public/Icons/Canto-FullLogo-White.svg @@ -0,0 +1 @@ + diff --git a/Resources/Public/Icons/Canto-Logo-White.svg b/Resources/Public/Icons/Canto-Logo-White.svg new file mode 100644 index 0000000..85837d2 --- /dev/null +++ b/Resources/Public/Icons/Canto-Logo-White.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..0f7bf65 --- /dev/null +++ b/composer.json @@ -0,0 +1,23 @@ +{ + "description": "Neos integration for Canto", + "type": "neos-package", + "name": "flownative/neos-canto", + "license": "MIT", + "require": { + "neos/flow": "6.* || dev-master", + "neos/media": "5.* || 6.* || dev-master", + "guzzlehttp/guzzle": "6.*", + "behat/transliterator": "~1.0", + "flownative/oauth2-client": "^2.1.0" + }, + "autoload": { + "psr-4": { + "Flownative\\Canto\\": "Classes/" + } + }, + "extra": { + "neos": { + "package-key": "Flownative.Canto" + } + } +}