diff options
Diffstat (limited to 'lib/public/Collaboration/Reference')
10 files changed, 866 insertions, 0 deletions
diff --git a/lib/public/Collaboration/Reference/ADiscoverableReferenceProvider.php b/lib/public/Collaboration/Reference/ADiscoverableReferenceProvider.php new file mode 100644 index 00000000000..582f51beea3 --- /dev/null +++ b/lib/public/Collaboration/Reference/ADiscoverableReferenceProvider.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCP\Collaboration\Reference; + +use JsonSerializable; + +/** + * @since 26.0.0 + */ +abstract class ADiscoverableReferenceProvider implements IDiscoverableReferenceProvider, JsonSerializable { + /** + * @since 26.0.0 + */ + public function jsonSerialize(): array { + $json = [ + 'id' => $this->getId(), + 'title' => $this->getTitle(), + 'icon_url' => $this->getIconUrl(), + 'order' => $this->getOrder(), + ]; + if ($this instanceof ISearchableReferenceProvider) { + $json['search_providers_ids'] = $this->getSupportedSearchProviderIds(); + } + return $json; + } +} diff --git a/lib/public/Collaboration/Reference/IDiscoverableReferenceProvider.php b/lib/public/Collaboration/Reference/IDiscoverableReferenceProvider.php new file mode 100644 index 00000000000..af4c6b3b1f6 --- /dev/null +++ b/lib/public/Collaboration/Reference/IDiscoverableReferenceProvider.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCP\Collaboration\Reference; + +/** + * @since 26.0.0 + */ +interface IDiscoverableReferenceProvider extends IReferenceProvider { + /** + * @return string Unique id that identifies the reference provider + * @since 26.0.0 + */ + public function getId(): string; + + /** + * @return string User facing title of the widget + * @since 26.0.0 + */ + public function getTitle(): string; + + /** + * @return int Initial order for reference provider sorting + * @since 26.0.0 + */ + public function getOrder(): int; + + /** + * @return string url to an icon that can be displayed next to the reference provider title + * @since 26.0.0 + */ + public function getIconUrl(): string; + + /** + * @return array representation of the provider + * @since 26.0.0 + */ + public function jsonSerialize(): array; +} diff --git a/lib/public/Collaboration/Reference/IPublicReferenceProvider.php b/lib/public/Collaboration/Reference/IPublicReferenceProvider.php new file mode 100644 index 00000000000..db6c3d3828b --- /dev/null +++ b/lib/public/Collaboration/Reference/IPublicReferenceProvider.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCP\Collaboration\Reference; + +/** + * @since 30.0.0 + */ +interface IPublicReferenceProvider extends IReferenceProvider { + /** + * Return a reference with its metadata for a given reference identifier and sharingToken + * + * @since 30.0.0 + */ + public function resolveReferencePublic(string $referenceText, string $sharingToken): ?IReference; + + /** + * Return a custom cache key to be used for caching the metadata + * This could be for example the current sharingToken if the reference + * access permissions are different for each share + * + * Should return null, if the cache is only related to the + * reference id and has no further dependency + * + * @since 30.0.0 + */ + public function getCacheKeyPublic(string $referenceId, string $sharingToken): ?string; +} diff --git a/lib/public/Collaboration/Reference/IReference.php b/lib/public/Collaboration/Reference/IReference.php new file mode 100644 index 00000000000..eaa82c323b5 --- /dev/null +++ b/lib/public/Collaboration/Reference/IReference.php @@ -0,0 +1,120 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCP\Collaboration\Reference; + +use JsonSerializable; + +/** + * @since 25.0.0 + */ +interface IReference extends JsonSerializable { + /** + * @since 25.0.0 + */ + public function getId(): string; + + /** + * Accessible flag indicates if the user has access to the provided reference + * + * @since 25.0.0 + */ + public function setAccessible(bool $accessible): void; + + /** + * Accessible flag indicates if the user has access to the provided reference + * + * @since 25.0.0 + */ + public function getAccessible(): bool; + + /** + * @since 25.0.0 + */ + public function setTitle(string $title): void; + + /** + * @since 25.0.0 + */ + public function getTitle(): string; + + /** + * @since 25.0.0 + */ + public function setDescription(?string $description): void; + + /** + * @since 25.0.0 + */ + public function getDescription(): ?string; + + /** + * @since 25.0.0 + */ + public function setImageUrl(?string $imageUrl): void; + + /** + * @since 25.0.0 + */ + public function getImageUrl(): ?string; + + /** + * @since 25.0.0 + */ + public function setImageContentType(?string $contentType): void; + + /** + * @since 25.0.0 + */ + public function getImageContentType(): ?string; + + /** + * @since 25.0.0 + */ + public function setUrl(?string $url): void; + + /** + * @since 25.0.0 + */ + public function getUrl(): string; + + /** + * Set the reference specific rich object representation + * + * @since 25.0.0 + */ + public function setRichObject(string $type, ?array $richObject): void; + + /** + * Returns the type of the reference specific rich object + * + * @since 25.0.0 + */ + public function getRichObjectType(): string; + + /** + * Returns the reference specific rich object representation + * + * @since 25.0.0 + */ + public function getRichObject(): array; + + /** + * Returns the opengraph rich object representation + * + * @since 25.0.0 + */ + public function getOpenGraphObject(): array; + + /** + * @return array{richObjectType: string, richObject: array<string, mixed>, openGraphObject: array{id: string, name: string, description: ?string, thumb: ?string, link: string}, accessible: bool} + * + * @since 25.0.0 + */ + public function jsonSerialize(): array; +} diff --git a/lib/public/Collaboration/Reference/IReferenceManager.php b/lib/public/Collaboration/Reference/IReferenceManager.php new file mode 100644 index 00000000000..c3cf7ca8e7b --- /dev/null +++ b/lib/public/Collaboration/Reference/IReferenceManager.php @@ -0,0 +1,84 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCP\Collaboration\Reference; + +/** + * @since 25.0.0 + */ +interface IReferenceManager { + /** + * Return all reference identifiers within a string as an array + * + * @return string[] Array of found references (urls) + * @since 25.0.0 + */ + public function extractReferences(string $text): array; + + /** + * Resolve a given reference id to its metadata with all available providers + * + * This method has a fallback to always provide the open graph metadata, + * but may still return null in case this is disabled or the fetching fails + * + * @since 25.0.0 + * @since 30.0.0 optional arguments `$public` and `$sharingToken` + */ + public function resolveReference(string $referenceId, bool $public = false, string $sharingToken = ''): ?IReference; + + /** + * Get a reference by its cache key + * + * @since 25.0.0 + */ + public function getReferenceByCacheKey(string $cacheKey): ?IReference; + + /** + * Explicitly get a reference from the cache to avoid heavy fetches for cases + * the cache can then be filled with a separate request from the frontend + * + * @since 25.0.0 + * @since 30.0.0 optional arguments `$public` and `$sharingToken` + */ + public function getReferenceFromCache(string $referenceId, bool $public = false, string $sharingToken = ''): ?IReference; + + /** + * Invalidate all cache entries with a prefix or just one if the cache key is provided + * + * @since 25.0.0 + */ + public function invalidateCache(string $cachePrefix, ?string $cacheKey = null): void; + + /** + * Get information on discoverable reference providers (id, title, icon and order) + * If the provider is searchable, also get the list of supported unified search providers + * + * @return IDiscoverableReferenceProvider[] + * @since 26.0.0 + */ + public function getDiscoverableProviders(): array; + + /** + * Update or set the last used timestamp for a provider + * + * @param string $userId + * @param string $providerId + * @param int|null $timestamp use current timestamp if null + * @return bool + * @since 26.0.0 + */ + public function touchProvider(string $userId, string $providerId, ?int $timestamp = null): bool; + + /** + * Get all known last used timestamps for reference providers + * + * @return int[] + * @since 26.0.0 + */ + public function getUserProviderTimestamps(): array; +} diff --git a/lib/public/Collaboration/Reference/IReferenceProvider.php b/lib/public/Collaboration/Reference/IReferenceProvider.php new file mode 100644 index 00000000000..9ff9a728063 --- /dev/null +++ b/lib/public/Collaboration/Reference/IReferenceProvider.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCP\Collaboration\Reference; + +/** + * @since 25.0.0 + */ +interface IReferenceProvider { + /** + * Validate that a given reference identifier matches the current provider + * + * @since 25.0.0 + */ + public function matchReference(string $referenceText): bool; + + /** + * Return a reference with its metadata for a given reference identifier + * + * @since 25.0.0 + */ + public function resolveReference(string $referenceText): ?IReference; + + /** + * Return true if the reference metadata can be globally cached + * + * @since 25.0.0 + */ + public function getCachePrefix(string $referenceId): string; + + /** + * Return a custom cache key to be used for caching the metadata + * This could be for example the current user id if the reference + * access permissions are different for each user + * + * Should return null, if the cache is only related to the + * reference id and has no further dependency + * + * @since 25.0.0 + */ + public function getCacheKey(string $referenceId): ?string; +} diff --git a/lib/public/Collaboration/Reference/ISearchableReferenceProvider.php b/lib/public/Collaboration/Reference/ISearchableReferenceProvider.php new file mode 100644 index 00000000000..d66a9de9453 --- /dev/null +++ b/lib/public/Collaboration/Reference/ISearchableReferenceProvider.php @@ -0,0 +1,20 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCP\Collaboration\Reference; + +/** + * @since 26.0.0 + */ +interface ISearchableReferenceProvider extends IDiscoverableReferenceProvider { + /** + * @return string[] list of search provider IDs that can be used by the vue-richtext picker + * @since 26.0.0 + */ + public function getSupportedSearchProviderIds(): array; +} diff --git a/lib/public/Collaboration/Reference/LinkReferenceProvider.php b/lib/public/Collaboration/Reference/LinkReferenceProvider.php new file mode 100644 index 00000000000..65bdcecb577 --- /dev/null +++ b/lib/public/Collaboration/Reference/LinkReferenceProvider.php @@ -0,0 +1,227 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCP\Collaboration\Reference; + +use Fusonic\OpenGraph\Consumer; +use GuzzleHttp\Psr7\LimitStream; +use GuzzleHttp\Psr7\Utils; +use OC\Security\RateLimiting\Exception\RateLimitExceededException; +use OC\Security\RateLimiting\Limiter; +use OC\SystemConfig; +use OCP\Files\AppData\IAppDataFactory; +use OCP\Files\NotFoundException; +use OCP\Http\Client\IClientService; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; + +/** + * @since 29.0.0 + */ +class LinkReferenceProvider implements IReferenceProvider, IPublicReferenceProvider { + + /** + * for image size and webpage header + * @since 29.0.0 + */ + public const MAX_CONTENT_LENGTH = 5 * 1024 * 1024; + + /** + * @since 29.0.0 + */ + public const ALLOWED_CONTENT_TYPES = [ + 'image/png', + 'image/jpg', + 'image/jpeg', + 'image/gif', + 'image/svg+xml', + 'image/webp' + ]; + + /** + * @since 29.0.0 + */ + public function __construct( + private IClientService $clientService, + private LoggerInterface $logger, + private SystemConfig $systemConfig, + private IAppDataFactory $appDataFactory, + private IURLGenerator $urlGenerator, + private Limiter $limiter, + private IUserSession $userSession, + private IRequest $request, + ) { + } + + /** + * @inheritDoc + * @since 29.0.0 + */ + public function matchReference(string $referenceText): bool { + if ($this->systemConfig->getValue('reference_opengraph', true) !== true) { + return false; + } + + return (bool)preg_match(IURLGenerator::URL_REGEX, $referenceText); + } + + /** + * @inheritDoc + * @since 29.0.0 + */ + public function resolveReference(string $referenceText): ?IReference { + if ($this->matchReference($referenceText)) { + $reference = new Reference($referenceText); + $this->fetchReference($reference); + return $reference; + } + + return null; + } + + /** + * @inheritDoc + * @since 30.0.0 + */ + public function resolveReferencePublic(string $referenceText, string $sharingToken): ?IReference { + return $this->resolveReference($referenceText); + } + + /** + * Populates the reference with OpenGraph data + * + * @param Reference $reference + * @since 29.0.0 + */ + private function fetchReference(Reference $reference): void { + try { + $user = $this->userSession->getUser(); + if ($user) { + $this->limiter->registerUserRequest('opengraph', 10, 120, $user); + } else { + $this->limiter->registerAnonRequest('opengraph', 10, 120, $this->request->getRemoteAddress()); + } + } catch (RateLimitExceededException $e) { + return; + } + + $client = $this->clientService->newClient(); + try { + $headResponse = $client->head($reference->getId(), [ 'timeout' => 3 ]); + } catch (\Exception $e) { + $this->logger->debug('Failed to perform HEAD request to get target metadata', ['exception' => $e]); + return; + } + + $linkContentLength = $headResponse->getHeader('Content-Length'); + if (is_numeric($linkContentLength) && (int)$linkContentLength > self::MAX_CONTENT_LENGTH) { + $this->logger->debug('[Head] Skip resolving links pointing to content length > 5 MiB'); + return; + } + + $linkContentType = $headResponse->getHeader('Content-Type'); + $expectedContentTypeRegex = '/^text\/html;?/i'; + + // check the header begins with the expected content type + if (!preg_match($expectedContentTypeRegex, $linkContentType)) { + $this->logger->debug('Skip resolving links pointing to content type that is not "text/html"'); + return; + } + + try { + $response = $client->get($reference->getId(), [ 'timeout' => 3, 'stream' => true ]); + } catch (\Exception $e) { + $this->logger->debug('Failed to fetch link for obtaining open graph data', ['exception' => $e]); + return; + } + + $body = $response->getBody(); + if (is_resource($body)) { + $responseContent = fread($body, self::MAX_CONTENT_LENGTH); + if (!feof($body)) { + $this->logger->debug('[Get] Skip resolving links pointing to content length > 5 MiB'); + return; + } + } else { + $this->logger->error('[Get] Impossible to check content length'); + return; + } + + // OpenGraph handling + $consumer = new Consumer(); + $consumer->useFallbackMode = true; + $object = $consumer->loadHtml($responseContent); + + $reference->setUrl($reference->getId()); + + if ($object->title) { + $reference->setTitle($object->title); + } + + if ($object->description) { + $reference->setDescription($object->description); + } + + if ($object->images) { + try { + $host = parse_url($object->images[0]->url, PHP_URL_HOST); + if ($host === false || $host === null) { + $this->logger->warning('Could not detect host of open graph image URI for ' . $reference->getId()); + return; + } + + $appData = $this->appDataFactory->get('core'); + try { + $folder = $appData->getFolder('opengraph'); + } catch (NotFoundException $e) { + $folder = $appData->newFolder('opengraph'); + } + + $response = $client->get($object->images[0]->url, ['timeout' => 3]); + $contentType = $response->getHeader('Content-Type'); + $contentLength = $response->getHeader('Content-Length'); + + if (in_array($contentType, self::ALLOWED_CONTENT_TYPES, true) && $contentLength < self::MAX_CONTENT_LENGTH) { + $stream = Utils::streamFor($response->getBody()); + $bodyStream = new LimitStream($stream, self::MAX_CONTENT_LENGTH, 0); + $reference->setImageContentType($contentType); + $folder->newFile(md5($reference->getId()), $bodyStream->getContents()); + $reference->setImageUrl($this->urlGenerator->linkToRouteAbsolute('core.Reference.preview', ['referenceId' => md5($reference->getId())])); + } + } catch (\Exception $e) { + $this->logger->debug('Failed to fetch and store the open graph image for ' . $reference->getId(), ['exception' => $e]); + } + } + } + + /** + * @inheritDoc + * @since 29.0.0 + */ + public function getCachePrefix(string $referenceId): string { + return $referenceId; + } + + /** + * @inheritDoc + * @since 29.0.0 + */ + public function getCacheKey(string $referenceId): ?string { + return null; + } + + /** + * @inheritDoc + * @since 30.0.0 + */ + public function getCacheKeyPublic(string $referenceId, string $sharingToken): ?string { + return null; + } +} diff --git a/lib/public/Collaboration/Reference/Reference.php b/lib/public/Collaboration/Reference/Reference.php new file mode 100644 index 00000000000..3698ae419b7 --- /dev/null +++ b/lib/public/Collaboration/Reference/Reference.php @@ -0,0 +1,237 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCP\Collaboration\Reference; + +/** + * @since 25.0.0 + * @psalm-type OpenGraphObject = array{id: string, name: string, description: ?string, thumb: ?string, link: string} + */ +class Reference implements IReference { + protected string $reference; + + protected bool $accessible = true; + + protected ?string $title = null; + protected ?string $description = null; + protected ?string $imageUrl = null; + protected ?string $contentType = null; + protected ?string $url = null; + + protected ?string $richObjectType = null; + protected ?array $richObject = null; + + /** + * @since 25.0.0 + */ + public function __construct(string $reference) { + $this->reference = $reference; + } + + /** + * @inheritdoc + * @since 25.0.0 + */ + public function getId(): string { + return $this->reference; + } + + /** + * @inheritdoc + * @since 25.0.0 + */ + public function setAccessible(bool $accessible): void { + $this->accessible = $accessible; + } + + /** + * @inheritdoc + * @since 25.0.0 + */ + public function getAccessible(): bool { + return $this->accessible; + } + + /** + * @inheritdoc + * @since 25.0.0 + */ + public function setTitle(string $title): void { + $this->title = $title; + } + + /** + * @inheritdoc + * @since 25.0.0 + */ + public function getTitle(): string { + return $this->title ?? $this->reference; + } + + /** + * @inheritdoc + * @since 25.0.0 + */ + public function setDescription(?string $description): void { + $this->description = $description; + } + + /** + * @inheritdoc + * @since 25.0.0 + */ + public function getDescription(): ?string { + return $this->description; + } + + /** + * @inheritdoc + * @since 25.0.0 + */ + public function setImageUrl(?string $imageUrl): void { + $this->imageUrl = $imageUrl; + } + + /** + * @inheritdoc + * @since 25.0.0 + */ + public function getImageUrl(): ?string { + return $this->imageUrl; + } + + /** + * @inheritdoc + * @since 25.0.0 + */ + public function setImageContentType(?string $contentType): void { + $this->contentType = $contentType; + } + + /** + * @inheritdoc + * @since 25.0.0 + */ + public function getImageContentType(): ?string { + return $this->contentType; + } + + /** + * @inheritdoc + * @since 25.0.0 + */ + public function setUrl(?string $url): void { + $this->url = $url; + } + + /** + * @inheritdoc + * @since 25.0.0 + */ + public function getUrl(): string { + return $this->url ?? $this->reference; + } + + /** + * @inheritdoc + * @since 25.0.0 + */ + public function setRichObject(string $type, ?array $richObject): void { + $this->richObjectType = $type; + $this->richObject = $richObject; + } + + /** + * @inheritdoc + * @since 25.0.0 + */ + public function getRichObjectType(): string { + if ($this->richObjectType === null) { + return 'open-graph'; + } + return $this->richObjectType; + } + + /** + * @inheritdoc + * @since 25.0.0 + * @return array<string, mixed> + */ + public function getRichObject(): array { + if ($this->richObject === null) { + return $this->getOpenGraphObject(); + } + return $this->richObject; + } + + /** + * @inheritdoc + * @since 25.0.0 + * @return OpenGraphObject + */ + public function getOpenGraphObject(): array { + return [ + 'id' => $this->getId(), + 'name' => $this->getTitle(), + 'description' => $this->getDescription(), + 'thumb' => $this->getImageUrl(), + 'link' => $this->getUrl() + ]; + } + + /** + * @param IReference $reference + * @return array + * @since 25.0.0 + */ + public static function toCache(IReference $reference): array { + return [ + 'id' => $reference->getId(), + 'title' => $reference->getTitle(), + 'imageUrl' => $reference->getImageUrl(), + 'imageContentType' => $reference->getImageContentType(), + 'description' => $reference->getDescription(), + 'link' => $reference->getUrl(), + 'accessible' => $reference->getAccessible(), + 'richObjectType' => $reference->getRichObjectType(), + 'richObject' => $reference->getRichObject(), + ]; + } + + /** + * @param array $cache + * @return IReference + * @since 25.0.0 + */ + public static function fromCache(array $cache): IReference { + $reference = new Reference($cache['id']); + $reference->setTitle($cache['title']); + $reference->setDescription($cache['description']); + $reference->setImageUrl($cache['imageUrl']); + $reference->setImageContentType($cache['imageContentType']); + $reference->setUrl($cache['link']); + $reference->setRichObject($cache['richObjectType'], $cache['richObject']); + $reference->setAccessible($cache['accessible']); + return $reference; + } + + /** + * @inheritdoc + * @since 25.0.0 + * @return array{richObjectType: string, richObject: array<string, mixed>, openGraphObject: OpenGraphObject, accessible: bool} + */ + public function jsonSerialize(): array { + return [ + 'richObjectType' => $this->getRichObjectType(), + 'richObject' => $this->getRichObject(), + 'openGraphObject' => $this->getOpenGraphObject(), + 'accessible' => $this->accessible + ]; + } +} diff --git a/lib/public/Collaboration/Reference/RenderReferenceEvent.php b/lib/public/Collaboration/Reference/RenderReferenceEvent.php new file mode 100644 index 00000000000..692098dbf60 --- /dev/null +++ b/lib/public/Collaboration/Reference/RenderReferenceEvent.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCP\Collaboration\Reference; + +use OCP\EventDispatcher\Event; + +/** + * Event emitted when apps might render references like link previews or smart picker widgets. + * + * This can be used to inject scripts for extending that. + * Further details can be found in the :ref:`Reference providers` deep dive. + * + * @since 25.0.0 + */ +class RenderReferenceEvent extends Event { +} |