diff options
Diffstat (limited to 'lib/private/Collaboration/Reference')
5 files changed, 543 insertions, 0 deletions
diff --git a/lib/private/Collaboration/Reference/File/FileReferenceEventListener.php b/lib/private/Collaboration/Reference/File/FileReferenceEventListener.php new file mode 100644 index 00000000000..9c18531c8e7 --- /dev/null +++ b/lib/private/Collaboration/Reference/File/FileReferenceEventListener.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Collaboration\Reference\File; + +use OC\Files\Node\NonExistingFile; +use OC\Files\Node\NonExistingFolder; +use OCP\Collaboration\Reference\IReferenceManager; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\Events\Node\NodeDeletedEvent; +use OCP\Files\Events\Node\NodeRenamedEvent; +use OCP\Share\Events\ShareCreatedEvent; +use OCP\Share\Events\ShareDeletedEvent; + +/** @template-implements IEventListener<Event|NodeDeletedEvent|ShareDeletedEvent|ShareCreatedEvent> */ +class FileReferenceEventListener implements IEventListener { + public function __construct( + private IReferenceManager $manager, + ) { + } + + public static function register(IEventDispatcher $eventDispatcher): void { + $eventDispatcher->addServiceListener(NodeDeletedEvent::class, FileReferenceEventListener::class); + $eventDispatcher->addServiceListener(NodeRenamedEvent::class, FileReferenceEventListener::class); + $eventDispatcher->addServiceListener(ShareDeletedEvent::class, FileReferenceEventListener::class); + $eventDispatcher->addServiceListener(ShareCreatedEvent::class, FileReferenceEventListener::class); + } + + /** + * @inheritDoc + */ + public function handle(Event $event): void { + if ($event instanceof NodeDeletedEvent) { + if ($event->getNode() instanceof NonExistingFolder || $event->getNode() instanceof NonExistingFile) { + return; + } + + $this->manager->invalidateCache((string)$event->getNode()->getId()); + } + if ($event instanceof NodeRenamedEvent) { + $this->manager->invalidateCache((string)$event->getTarget()->getId()); + } + if ($event instanceof ShareDeletedEvent) { + $this->manager->invalidateCache((string)$event->getShare()->getNodeId()); + } + if ($event instanceof ShareCreatedEvent) { + $this->manager->invalidateCache((string)$event->getShare()->getNodeId()); + } + } +} diff --git a/lib/private/Collaboration/Reference/File/FileReferenceProvider.php b/lib/private/Collaboration/Reference/File/FileReferenceProvider.php new file mode 100644 index 00000000000..3cb174d9607 --- /dev/null +++ b/lib/private/Collaboration/Reference/File/FileReferenceProvider.php @@ -0,0 +1,161 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Collaboration\Reference\File; + +use OC\User\NoUserException; +use OCP\Collaboration\Reference\ADiscoverableReferenceProvider; +use OCP\Collaboration\Reference\IReference; +use OCP\Collaboration\Reference\Reference; +use OCP\Files\IMimeTypeDetector; +use OCP\Files\InvalidPathException; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\IL10N; +use OCP\IPreview; +use OCP\IURLGenerator; +use OCP\IUserSession; +use OCP\L10N\IFactory; + +class FileReferenceProvider extends ADiscoverableReferenceProvider { + private ?string $userId; + private IL10N $l10n; + + public function __construct( + private IURLGenerator $urlGenerator, + private IRootFolder $rootFolder, + IUserSession $userSession, + private IMimeTypeDetector $mimeTypeDetector, + private IPreview $previewManager, + IFactory $l10n, + ) { + $this->userId = $userSession->getUser()?->getUID(); + $this->l10n = $l10n->get('files'); + } + + public function matchReference(string $referenceText): bool { + return $this->getFilesAppLinkId($referenceText) !== null; + } + + private function getFilesAppLinkId(string $referenceText): ?int { + $start = $this->urlGenerator->getAbsoluteURL('/apps/files/'); + $startIndex = $this->urlGenerator->getAbsoluteURL('/index.php/apps/files/'); + + $fileId = null; + + if (mb_strpos($referenceText, $start) === 0) { + $parts = parse_url($referenceText); + parse_str($parts['query'] ?? '', $query); + $fileId = isset($query['fileid']) ? (int)$query['fileid'] : $fileId; + $fileId = isset($query['openfile']) ? (int)$query['openfile'] : $fileId; + } + + if (mb_strpos($referenceText, $startIndex) === 0) { + $parts = parse_url($referenceText); + parse_str($parts['query'] ?? '', $query); + $fileId = isset($query['fileid']) ? (int)$query['fileid'] : $fileId; + $fileId = isset($query['openfile']) ? (int)$query['openfile'] : $fileId; + } + + if (mb_strpos($referenceText, $this->urlGenerator->getAbsoluteURL('/index.php/f/')) === 0) { + $fileId = str_replace($this->urlGenerator->getAbsoluteURL('/index.php/f/'), '', $referenceText); + } + + if (mb_strpos($referenceText, $this->urlGenerator->getAbsoluteURL('/f/')) === 0) { + $fileId = str_replace($this->urlGenerator->getAbsoluteURL('/f/'), '', $referenceText); + } + + return $fileId !== null ? (int)$fileId : null; + } + + public function resolveReference(string $referenceText): ?IReference { + if ($this->matchReference($referenceText)) { + $reference = new Reference($referenceText); + try { + $this->fetchReference($reference); + } catch (NotFoundException $e) { + $reference->setRichObject('file', null); + $reference->setAccessible(false); + } + return $reference; + } + + return null; + } + + /** + * @throws NotFoundException + */ + private function fetchReference(Reference $reference): void { + if ($this->userId === null) { + throw new NotFoundException(); + } + + $fileId = $this->getFilesAppLinkId($reference->getId()); + if ($fileId === null) { + throw new NotFoundException(); + } + + try { + $userFolder = $this->rootFolder->getUserFolder($this->userId); + $file = $userFolder->getFirstNodeById($fileId); + + if (!$file) { + throw new NotFoundException(); + } + + $reference->setTitle($file->getName()); + $reference->setDescription($file->getMimetype()); + $reference->setUrl($this->urlGenerator->getAbsoluteURL('/index.php/f/' . $fileId)); + if ($this->previewManager->isMimeSupported($file->getMimeType())) { + $reference->setImageUrl($this->urlGenerator->linkToRouteAbsolute('core.Preview.getPreviewByFileId', ['x' => 1600, 'y' => 630, 'fileId' => $fileId])); + } else { + $fileTypeIconUrl = $this->mimeTypeDetector->mimeTypeIcon($file->getMimeType()); + $reference->setImageUrl($fileTypeIconUrl); + } + + $reference->setRichObject('file', [ + 'id' => $file->getId(), + 'name' => $file->getName(), + 'size' => $file->getSize(), + 'path' => $userFolder->getRelativePath($file->getPath()), + 'link' => $reference->getUrl(), + 'mimetype' => $file->getMimetype(), + 'mtime' => $file->getMTime(), + 'preview-available' => $this->previewManager->isAvailable($file) + ]); + } catch (InvalidPathException|NotFoundException|NotPermittedException|NoUserException $e) { + throw new NotFoundException(); + } + } + + public function getCachePrefix(string $referenceId): string { + return (string)$this->getFilesAppLinkId($referenceId); + } + + public function getCacheKey(string $referenceId): ?string { + return $this->userId ?? ''; + } + + public function getId(): string { + return 'files'; + } + + public function getTitle(): string { + return $this->l10n->t('Files'); + } + + public function getOrder(): int { + return 0; + } + + public function getIconUrl(): string { + return $this->urlGenerator->imagePath('files', 'folder.svg'); + } +} diff --git a/lib/private/Collaboration/Reference/LinkReferenceProvider.php b/lib/private/Collaboration/Reference/LinkReferenceProvider.php new file mode 100644 index 00000000000..5af23bf633d --- /dev/null +++ b/lib/private/Collaboration/Reference/LinkReferenceProvider.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Collaboration\Reference; + +use OCP\Collaboration\Reference\LinkReferenceProvider as OCPLinkReferenceProvider; + +/** @deprecated 29.0.0 Use OCP\Collaboration\Reference\LinkReferenceProvider instead */ +class LinkReferenceProvider extends OCPLinkReferenceProvider { +} diff --git a/lib/private/Collaboration/Reference/ReferenceManager.php b/lib/private/Collaboration/Reference/ReferenceManager.php new file mode 100644 index 00000000000..9287b66b2a2 --- /dev/null +++ b/lib/private/Collaboration/Reference/ReferenceManager.php @@ -0,0 +1,262 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Collaboration\Reference; + +use OC\AppFramework\Bootstrap\Coordinator; +use OC\Collaboration\Reference\File\FileReferenceProvider; +use OCP\Collaboration\Reference\IDiscoverableReferenceProvider; +use OCP\Collaboration\Reference\IPublicReferenceProvider; +use OCP\Collaboration\Reference\IReference; +use OCP\Collaboration\Reference\IReferenceManager; +use OCP\Collaboration\Reference\IReferenceProvider; +use OCP\Collaboration\Reference\Reference; +use OCP\ICache; +use OCP\ICacheFactory; +use OCP\IConfig; +use OCP\IURLGenerator; +use OCP\IUserSession; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; +use Throwable; + +class ReferenceManager implements IReferenceManager { + public const CACHE_TTL = 3600; + + /** @var IReferenceProvider[]|null */ + private ?array $providers = null; + private ICache $cache; + + public function __construct( + private LinkReferenceProvider $linkReferenceProvider, + ICacheFactory $cacheFactory, + private Coordinator $coordinator, + private ContainerInterface $container, + private LoggerInterface $logger, + private IConfig $config, + private IUserSession $userSession, + ) { + $this->cache = $cacheFactory->createDistributed('reference'); + } + + /** + * Extract a list of URLs from a text + * + * @return string[] + */ + public function extractReferences(string $text): array { + preg_match_all(IURLGenerator::URL_REGEX, $text, $matches); + $references = $matches[0] ?? []; + return array_map(function ($reference) { + return trim($reference); + }, $references); + } + + /** + * Try to get a cached reference object from a reference string + */ + public function getReferenceFromCache(string $referenceId, bool $public = false, string $sharingToken = ''): ?IReference { + $matchedProvider = $this->getMatchedProvider($referenceId, $public); + + if ($matchedProvider === null) { + return null; + } + + $cacheKey = $this->getFullCacheKey($matchedProvider, $referenceId, $public, $sharingToken); + return $this->getReferenceByCacheKey($cacheKey); + } + + /** + * Try to get a cached reference object from a full cache key + */ + public function getReferenceByCacheKey(string $cacheKey): ?IReference { + $cached = $this->cache->get($cacheKey); + if ($cached) { + return Reference::fromCache($cached); + } + + return null; + } + + /** + * Get a reference object from a reference string with a matching provider + * Use a cached reference if possible + */ + public function resolveReference(string $referenceId, bool $public = false, $sharingToken = ''): ?IReference { + $matchedProvider = $this->getMatchedProvider($referenceId, $public); + + if ($matchedProvider === null) { + return null; + } + + $cacheKey = $this->getFullCacheKey($matchedProvider, $referenceId, $public, $sharingToken); + $cached = $this->cache->get($cacheKey); + if ($cached) { + return Reference::fromCache($cached); + } + + $reference = null; + if ($public && $matchedProvider instanceof IPublicReferenceProvider) { + $reference = $matchedProvider->resolveReferencePublic($referenceId, $sharingToken); + } elseif ($matchedProvider instanceof IReferenceProvider) { + $reference = $matchedProvider->resolveReference($referenceId); + } + if ($reference) { + $cachePrefix = $matchedProvider->getCachePrefix($referenceId); + if ($cachePrefix !== '') { + // If a prefix is used we set an additional key to know when we need to delete by prefix during invalidateCache() + $this->cache->set('hasPrefix-' . md5($cachePrefix), true, self::CACHE_TTL); + } + $this->cache->set($cacheKey, Reference::toCache($reference), self::CACHE_TTL); + return $reference; + } + + return null; + } + + /** + * Try to match a reference string with all the registered providers + * Fallback to the link reference provider (using OpenGraph) + * + * @return IReferenceProvider|IPublicReferenceProvider|null the first matching provider + */ + private function getMatchedProvider(string $referenceId, bool $public): null|IReferenceProvider|IPublicReferenceProvider { + $matchedProvider = null; + foreach ($this->getProviders() as $provider) { + if ($public && !($provider instanceof IPublicReferenceProvider)) { + continue; + } + $matchedProvider = $provider->matchReference($referenceId) ? $provider : null; + if ($matchedProvider !== null) { + break; + } + } + + if ($matchedProvider === null && $this->linkReferenceProvider->matchReference($referenceId)) { + $matchedProvider = $this->linkReferenceProvider; + } + + return $matchedProvider; + } + + /** + * Get a hashed full cache key from a key and prefix given by a provider + */ + private function getFullCacheKey(IReferenceProvider $provider, string $referenceId, bool $public, string $sharingToken): string { + if ($public && !($provider instanceof IPublicReferenceProvider)) { + throw new \RuntimeException('Provider doesn\'t support public lookups'); + } + $cacheKey = $public + ? $provider->getCacheKeyPublic($referenceId, $sharingToken) + : $provider->getCacheKey($referenceId); + return md5($provider->getCachePrefix($referenceId)) . ( + $cacheKey !== null ? ('-' . md5($cacheKey)) : '' + ); + } + + /** + * Remove a specific cache entry from its key+prefix + */ + public function invalidateCache(string $cachePrefix, ?string $cacheKey = null): void { + if ($cacheKey === null) { + // clear might be a heavy operation, so we only do it if there have actually been keys set + if ($this->cache->remove('hasPrefix-' . md5($cachePrefix))) { + $this->cache->clear(md5($cachePrefix)); + } + + return; + } + + $this->cache->remove(md5($cachePrefix) . '-' . md5($cacheKey)); + } + + /** + * @return IReferenceProvider[] + */ + public function getProviders(): array { + if ($this->providers === null) { + $context = $this->coordinator->getRegistrationContext(); + if ($context === null) { + return []; + } + + $this->providers = array_filter(array_map(function ($registration): ?IReferenceProvider { + try { + /** @var IReferenceProvider $provider */ + $provider = $this->container->get($registration->getService()); + } catch (Throwable $e) { + $this->logger->error('Could not load reference provider ' . $registration->getService() . ': ' . $e->getMessage(), [ + 'exception' => $e, + ]); + return null; + } + + return $provider; + }, $context->getReferenceProviders())); + + $this->providers[] = $this->container->get(FileReferenceProvider::class); + } + + return $this->providers; + } + + /** + * @inheritDoc + */ + public function getDiscoverableProviders(): array { + // preserve 0 based index to avoid returning an object in data responses + return array_values( + array_filter($this->getProviders(), static function (IReferenceProvider $provider) { + return $provider instanceof IDiscoverableReferenceProvider; + }) + ); + } + + /** + * @inheritDoc + */ + public function touchProvider(string $userId, string $providerId, ?int $timestamp = null): bool { + $providers = $this->getDiscoverableProviders(); + $matchingProviders = array_filter($providers, static function (IDiscoverableReferenceProvider $provider) use ($providerId) { + return $provider->getId() === $providerId; + }); + if (!empty($matchingProviders)) { + if ($timestamp === null) { + $timestamp = time(); + } + + $configKey = 'provider-last-use_' . $providerId; + $this->config->setUserValue($userId, 'references', $configKey, (string)$timestamp); + return true; + } + return false; + } + + /** + * @inheritDoc + */ + public function getUserProviderTimestamps(): array { + $user = $this->userSession->getUser(); + if ($user === null) { + return []; + } + $userId = $user->getUID(); + $keys = $this->config->getUserKeys($userId, 'references'); + $prefix = 'provider-last-use_'; + $keys = array_filter($keys, static function (string $key) use ($prefix) { + return str_starts_with($key, $prefix); + }); + $timestamps = []; + foreach ($keys as $key) { + $providerId = substr($key, strlen($prefix)); + $timestamp = (int)$this->config->getUserValue($userId, 'references', $key); + $timestamps[$providerId] = $timestamp; + } + return $timestamps; + } +} diff --git a/lib/private/Collaboration/Reference/RenderReferenceEventListener.php b/lib/private/Collaboration/Reference/RenderReferenceEventListener.php new file mode 100644 index 00000000000..9e6192314cb --- /dev/null +++ b/lib/private/Collaboration/Reference/RenderReferenceEventListener.php @@ -0,0 +1,48 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Collaboration\Reference; + +use OCP\Collaboration\Reference\IDiscoverableReferenceProvider; +use OCP\Collaboration\Reference\IReferenceManager; +use OCP\Collaboration\Reference\RenderReferenceEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\EventDispatcher\IEventListener; +use OCP\IInitialStateService; + +/** @template-implements IEventListener<Event|RenderReferenceEvent> */ +class RenderReferenceEventListener implements IEventListener { + public function __construct( + private IReferenceManager $manager, + private IInitialStateService $initialStateService, + ) { + } + + public static function register(IEventDispatcher $eventDispatcher): void { + $eventDispatcher->addServiceListener(RenderReferenceEvent::class, RenderReferenceEventListener::class); + } + + /** + * @inheritDoc + */ + public function handle(Event $event): void { + if (!($event instanceof RenderReferenceEvent)) { + return; + } + + $providers = $this->manager->getDiscoverableProviders(); + $jsonProviders = array_map(static function (IDiscoverableReferenceProvider $provider) { + return $provider->jsonSerialize(); + }, $providers); + $this->initialStateService->provideInitialState('core', 'reference-provider-list', $jsonProviders); + + $timestamps = $this->manager->getUserProviderTimestamps(); + $this->initialStateService->provideInitialState('core', 'reference-provider-timestamps', $timestamps); + } +} |