From 7b9abadc066081e936e5cc1fb6019674fa933e49 Mon Sep 17 00:00:00 2001 From: Jonas Date: Tue, 16 Jul 2024 11:50:10 +0200 Subject: [PATCH] feat(PageReference): Public page reference lookups Allow to resolve page references by share token to support lookups from public shares. This adds link previews for links to other pages in the same collective in public shares. It requires the new public reference API from nextcloud/server#46378 and the Text app being built with nextcloud-libraries/nextcloud-vue#5800. Fixes: #1275 Contributes to nextcloud/text#5520 Signed-off-by: Jonas --- lib/Db/Collective.php | 12 +- .../SearchablePageReferenceProvider.php | 273 +++++++++++------- lib/Service/CollectiveService.php | 21 ++ lib/Service/CollectiveShareService.php | 6 +- lib/Service/PageService.php | 12 +- lib/Service/SharePageService.php | 38 +++ src/Collectives.vue | 2 + .../SearchablePageReferenceProviderTest.php | 115 ++++++++ tests/Unit/Service/CollectiveServiceTest.php | 1 + 9 files changed, 377 insertions(+), 103 deletions(-) create mode 100644 lib/Service/SharePageService.php create mode 100644 tests/Unit/Search/SearchablePageReferenceProviderTest.php diff --git a/lib/Db/Collective.php b/lib/Db/Collective.php index 40916ce55..0adfe5584 100644 --- a/lib/Db/Collective.php +++ b/lib/Db/Collective.php @@ -58,10 +58,11 @@ class Collective extends Entity implements JsonSerializable { protected ?int $trashTimestamp = null; protected int $pageMode = self::defaultPageMode; /** transient attributes, not persisted in database */ - protected string $name; + protected string $name = ''; protected int $level = Member::LEVEL_MEMBER; protected ?string $shareToken = null; protected bool $isPageShare = false; + protected int $sharePageId = 0; protected bool $shareEditable = false; protected int $userPageOrder = Collective::defaultPageOrder; protected bool $userShowRecentPages = Collective::defaultShowRecentPages; @@ -158,6 +159,14 @@ public function setIsPageShare(bool $isPageShare): void { $this->isPageShare = $isPageShare; } + public function getSharePageId(): int { + return $this->sharePageId; + } + + public function setSharePageId(int $sharePageId): void { + $this->sharePageId = $sharePageId; + } + public function getShareEditable(): bool { return $this->shareEditable; } @@ -251,6 +260,7 @@ public function jsonSerialize(): array { 'canShare' => $this->canShare(), 'shareToken' => $this->shareToken, 'isPageShare' => $this->isPageShare, + 'sharePageId' => $this->sharePageId, 'shareEditable' => $this->canEdit() && $this->shareEditable, 'userPageOrder' => $this->userPageOrder, 'userShowRecentPages' => $this->userShowRecentPages, diff --git a/lib/Reference/SearchablePageReferenceProvider.php b/lib/Reference/SearchablePageReferenceProvider.php index 96ab97e7e..8a064b788 100644 --- a/lib/Reference/SearchablePageReferenceProvider.php +++ b/lib/Reference/SearchablePageReferenceProvider.php @@ -9,11 +9,14 @@ use OC\Collaboration\Reference\LinkReferenceProvider; use OC\Collaboration\Reference\ReferenceManager; use OCA\Collectives\AppInfo\Application; +use OCA\Collectives\Db\Collective; use OCA\Collectives\Model\PageInfo; use OCA\Collectives\Service\CollectiveService; use OCA\Collectives\Service\NotFoundException; use OCA\Collectives\Service\PageService; +use OCA\Collectives\Service\SharePageService; use OCP\Collaboration\Reference\ADiscoverableReferenceProvider; +use OCP\Collaboration\Reference\IPublicReferenceProvider; use OCP\Collaboration\Reference\IReference; use OCP\Collaboration\Reference\ISearchableReferenceProvider; use OCP\Collaboration\Reference\Reference; @@ -22,18 +25,20 @@ use OCP\IURLGenerator; use Throwable; -class SearchablePageReferenceProvider extends ADiscoverableReferenceProvider implements ISearchableReferenceProvider { +class SearchablePageReferenceProvider extends ADiscoverableReferenceProvider implements ISearchableReferenceProvider, IPublicReferenceProvider { private const RICH_OBJECT_TYPE = Application::APP_NAME . '_page'; - public function __construct(private CollectiveService $collectiveService, + public function __construct( + private CollectiveService $collectiveService, private PageService $pageService, + private SharePageService $sharePageService, private IL10N $l10n, private IURLGenerator $urlGenerator, private IDateTimeFormatter $dateTimeFormatter, private ReferenceManager $referenceManager, private LinkReferenceProvider $linkReferenceProvider, - private ?string $userId) { - } + private ?string $userId, + ) {} public function getId(): string { return Application::APP_NAME . '-ref-pages'; @@ -57,125 +62,199 @@ public function getSupportedSearchProviderIds(): array { return ['collectives-pages']; } - public function matchReference(string $referenceText): bool { - if ($this->userId === null) { - return false; + private static function pagePathFromMatches(string $url, array $matches): array { + $pagePath = [ + 'collectiveName' => urldecode($matches[1]), + 'pagePath' => urldecode($matches[2]), + ]; + preg_match('/\?fileId=(\d+)$/i', $url, $matches); + if ($matches && count($matches) > 1) { + $pagePath['fileId'] = (int) $matches[1]; + } + return $pagePath; + } + + public function matchUrl(string $url): ?array { + // link examples: + // https://nextcloud.local/apps/collectives/supacollective/p/MsdwSCmP9F6jcQX/Tutos/Hacking/Spectre?fileId=14457 + // https://nextcloud.local/apps/collectives/supacollective/p/MsdwSCmP9F6jcQX/Tutos/Hacking/Spectre + // https://nextcloud.local/apps/collectives/supacollective/index.php/p/MsdwSCmP9F6jcQX/Tutos/Hacking/Spectre?fileId=14457 + $startPublicRegexes = [ + $this->urlGenerator->getAbsoluteURL('/apps/' . Application::APP_NAME . '/p'), + $this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APP_NAME . '/p'), + ]; + + $matches = false; + foreach ($startPublicRegexes as $regex) { + preg_match('/^' . preg_quote($regex, '/') . '\/\w+' . '\/([^\/]+)\/([^?]+)/i', $url, $matches); + if ($matches && count($matches) > 2) { + return self::pagePathFromMatches($url, $matches); + } } - $start = $this->urlGenerator->getAbsoluteURL('/apps/' . Application::APP_NAME); - $startIndex = $this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APP_NAME); - // link example: + // link examples: // https://nextcloud.local/apps/collectives/supacollective/Tutos/Hacking/Spectre?fileId=14457 // https://nextcloud.local/apps/collectives/supacollective/Tutos/Hacking/Spectre - $noIndexMatch = preg_match('/^' . preg_quote($start, '/') . '\/[^\/]+\//i', $referenceText) === 1; - $indexMatch = preg_match('/^' . preg_quote($startIndex, '/') . '\/[^\/]+\//i', $referenceText) === 1; + $startRegexes = [ + $this->urlGenerator->getAbsoluteURL('/apps/' . Application::APP_NAME), + $this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APP_NAME), + ]; - return $noIndexMatch || $indexMatch; + foreach ($startRegexes as $regex) { + preg_match('/^' . preg_quote($regex, '/') . '\/([^\/]+)\/([^?]+)/i', $url, $matches); + if ($matches && count($matches) > 2) { + return self::pagePathFromMatches($url, $matches); + } + } + + return null; } - public function resolveReference(string $referenceText): ?IReference { - if ($this->matchReference($referenceText)) { - $pageReferenceInfo = $this->getPagePathFromDirectLink($referenceText); - if (!$pageReferenceInfo) { - // fallback to opengraph if it matches, but somehow we can't resolve - return $this->linkReferenceProvider->resolveReference($referenceText); - } + public function matchReference(string $referenceText): bool { + return (bool)$this->matchUrl($referenceText); + } + + /** + * @throws NotFoundException + */ + private function getCollective(string $collectiveName, ?string $sharingToken): Collective { + if ($sharingToken) { + // TODO: Check if share is password protected; if yes, then check in session if authenticated + return $this->collectiveService->findCollectiveByShare($sharingToken); + } + + return $this->collectiveService->findCollectiveByName($this->userId, $collectiveName); + } + + /** + * @throws NotFoundException + */ + private function getPage(Collective $collective, array $pageReferenceInfo, bool $public): PageInfo { + if ($public && !$collective->getShareToken()) { + throw new NotFoundException('Collective share token is missing'); + } - $collectiveName = $pageReferenceInfo['collectiveName']; + if (isset($pageReferenceInfo['fileId'])) { + $page = $public + ? $this->sharePageService->findSharePageById($collective->getShareToken(), $pageReferenceInfo['fileId']) + : $this->pageService->findByFileId($collective->getId(), $pageReferenceInfo['fileId'], $this->userId); + } else { try { - $collective = $this->collectiveService->findCollectiveByName($this->userId, $collectiveName); - if (isset($pageReferenceInfo['fileId'])) { - $page = $this->pageService->findByFileId($collective->getId(), $pageReferenceInfo['fileId'], $this->userId); - } else { - try { - $page = $this->pageService->findByPath($collective->getId(), $pageReferenceInfo['pagePath'], $this->userId); - } catch (NotFoundException) { - $pathInfo = pathinfo($pageReferenceInfo['pagePath']); - if (!$pathInfo || !array_key_exists('extension', $pathInfo)) { - throw new NotFoundException('Pathinfo for page path is incomplete'); - } - if ('.' . $pathInfo['extension'] === PageInfo::SUFFIX) { - if ($pathInfo['filename'] === PageInfo::INDEX_PAGE_TITLE) { - // try to find page by stripping `/Readme.md` - $page = $this->pageService->findByPath($collective->getId(), $pathInfo['dirname'], $this->userId); - } else { - // try to find page by stripping `.md` - $page = $this->pageService->findByPath($collective->getId(), $pathInfo['filename'], $this->userId); - } - } + $page = $public + ? $this->sharePageService->findSharePageByPath($collective->getShareToken(), $pageReferenceInfo['pagePath']) + : $this->pageService->findByPath($collective->getId(), $pageReferenceInfo['pagePath'], $this->userId); + } catch (NotFoundException) { + $pathInfo = pathinfo($pageReferenceInfo['pagePath']); + if (!$pathInfo || !array_key_exists('extension', $pathInfo)) { + throw new NotFoundException('Pathinfo for page path is incomplete'); + } + if ('.' . $pathInfo['extension'] === PageInfo::SUFFIX) { + if ($pathInfo['filename'] === PageInfo::INDEX_PAGE_TITLE) { + // try to find page by stripping `/Readme.md` + $page = $public + ? $this->sharePageService->findSharePageByPath($collective->getShareToken(), $pathInfo['dirname']) + : $this->pageService->findByPath($collective->getId(), $pathInfo['dirname'], $this->userId); + } else { + // try to find page by stripping `.md` + $page = $public + ? $this->sharePageService->findSharePageByPath($collective->getShareToken(), $pathInfo['filename']) + : $this->pageService->findByPath($collective->getId(), $pathInfo['filename'], $this->userId); } + } else { + throw new NotFoundException('Pathinfo for page path is incomplete'); } - } catch (Exception | Throwable) { - // fallback to opengraph if it matches, but somehow we can't resolve - return $this->linkReferenceProvider->resolveReference($referenceText); } - - $pageReferenceInfo['collective'] = $collective; - $pageReferenceInfo['page'] = $page; - - $link = $this->urlGenerator->linkToRouteAbsolute('collectives.start.index') . $this->pageService->getPageLink($collective->getName(), $page); - $reference = new Reference($link); - $pageEmoji = $page->getEmoji(); - $refTitle = $pageEmoji ? $pageEmoji . ' ' . $page->getTitle() : $page->getTitle(); - $reference->setTitle($refTitle); - - $description = $this->l10n->t('In collective %1$s', [$this->collectiveService->getCollectiveNameWithEmoji($collective)]) - . ' - ' . $page->getFilePath(); - $reference->setDescription($description); - $pageReferenceInfo['description'] = $description; - - $date = new DateTime(); - $date->setTimestamp($page->getTimestamp()); - $formattedRelativeDate = $this->dateTimeFormatter->formatTimeSpan($date); - $pageReferenceInfo['lastEdited'] = $this->l10n->t('Last edition %1$s', [$formattedRelativeDate]); - - $imageUrl = $this->urlGenerator->getAbsoluteURL( - $this->urlGenerator->imagePath(Application::APP_NAME, 'page.svg') - ); - $reference->setImageUrl($imageUrl); - - $pageReferenceInfo['link'] = $link; - $reference->setUrl($link); - - $reference->setRichObject( - self::RICH_OBJECT_TYPE, - $pageReferenceInfo, - ); - return $reference; } - return null; + return $page; } - public function getPagePathFromDirectLink(string $url): ?array { - $start = $this->urlGenerator->getAbsoluteURL('/apps/' . Application::APP_NAME); - $startIndex = $this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APP_NAME); + private function resolve(string $referenceText, bool $public = false, string $sharingToken = ''): ?IReference { + if (!$this->matchReference($referenceText)) { + return null; + } - preg_match('/^' . preg_quote($start, '/') . '\/([^\/]+)\/([^?]+)/i', $url, $matches); - if (!$matches || count($matches) < 3) { - preg_match('/^' . preg_quote($startIndex, '/') . '\/([^\/]+)\/([^?]+)/i', $url, $matches); + $pageReferenceInfo = $this->getPagePathFromDirectLink($referenceText); + if (!$pageReferenceInfo) { + // fallback to opengraph if it matches, but somehow we can't resolve + return $this->linkReferenceProvider->resolveReference($referenceText); } - if ($matches && count($matches) > 2) { - $pagePath = [ - 'collectiveName' => urldecode($matches[1]), - 'pagePath' => urldecode($matches[2]), - ]; - preg_match('/\?fileId=(\d+)$/i', $url, $matches); - if ($matches && count($matches) > 1) { - $pagePath['fileId'] = (int) $matches[1]; - } - return $pagePath; + + $collectiveName = $pageReferenceInfo['collectiveName']; + + if ($public && !$sharingToken) { + // fallback to opengraph for public lookups without share token + return $this->linkReferenceProvider->resolveReference($referenceText); } - return null; + try { + $collective = $this->getCollective($collectiveName, $sharingToken); + $page = $this->getPage($collective, $pageReferenceInfo, $public); + } catch (Exception | Throwable) { + // fallback to opengraph if it matches, but somehow we can't resolve + return $this->linkReferenceProvider->resolveReference($referenceText); + } + + $pageReferenceInfo['collective'] = $collective; + $pageReferenceInfo['page'] = $page; + + $collectiveLink = $this->urlGenerator->linkToRouteAbsolute('collectives.start.index') . ($public ? 'p/' . $sharingToken . '/' : '') . $collectiveName . '/'; + $link = $collectiveLink . $this->pageService->getPageLink($collective->getName(), $page); + $reference = new Reference($link); + $pageEmoji = $page->getEmoji(); + $refTitle = $pageEmoji ? $pageEmoji . ' ' . $page->getTitle() : $page->getTitle(); + $reference->setTitle($refTitle); + + $description = $this->l10n->t('In collective %1$s', [$this->collectiveService->getCollectiveNameWithEmoji($collective)]) + . ' - ' . $page->getFilePath(); + $reference->setDescription($description); + $pageReferenceInfo['description'] = $description; + + $date = new DateTime(); + $date->setTimestamp($page->getTimestamp()); + $formattedRelativeDate = $this->dateTimeFormatter->formatTimeSpan($date); + $pageReferenceInfo['lastEdited'] = $this->l10n->t('Last edition %1$s', [$formattedRelativeDate]); + + $imageUrl = $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->imagePath(Application::APP_NAME, 'page.svg') + ); + $reference->setImageUrl($imageUrl); + + $pageReferenceInfo['link'] = $link; + $reference->setUrl($link); + + $reference->setRichObject( + self::RICH_OBJECT_TYPE, + $pageReferenceInfo, + ); + return $reference; + } + + public function resolveReference(string $referenceText): ?IReference { + if ($this->userId === null) { + return $this->linkReferenceProvider->resolveReference($referenceText); + } + return $this->resolve($referenceText); + } + + public function resolveReferencePublic(string $referenceText, string $sharingToken): ?IReference { + return $this->resolve($referenceText, true, $sharingToken); + } + + public function getPagePathFromDirectLink(string $url): ?array { + return $this->matchUrl($url); } public function getCachePrefix(string $referenceId): string { - return $this->userId ?? ''; + return $referenceId; } public function getCacheKey(string $referenceId): ?string { - return $referenceId; + return $this->userId ?? ''; + } + + public function getCacheKeyPublic(string $referenceId, string $sharingToken): ?string { + return $sharingToken; } public function invalidateUserCache(string $userId): void { diff --git a/lib/Service/CollectiveService.php b/lib/Service/CollectiveService.php index 209c1c6ab..99d13a278 100644 --- a/lib/Service/CollectiveService.php +++ b/lib/Service/CollectiveService.php @@ -65,6 +65,7 @@ public function getCollectiveWithShare(int $id, string $userId, ?string $shareTo if ($collective->getId() === $share->getCollectiveId()) { $collective->setShareToken($share->getToken()); $collective->setIsPageShare($share->getPageId() !== 0); + $collective->setSharePageId($share->getPageId()); $collective->setShareEditable($share->getEditable()); } else { throw new NotFoundException('Share token does not match collective.'); @@ -124,6 +125,26 @@ public function findCollectiveByName(string $userId, string $name): Collective { return end($collectives); } + /** + * @throws MissingDependencyException + * @throws NotFoundException + * @throws NotPermittedException + */ + public function findCollectiveByShare(string $shareToken): Collective { + if (null === $share = $this->shareService->findShareByToken($shareToken, false)) { + throw new NotFoundException('Unable to find a collective from its share token'); + } + + if (null === $collective = $this->collectiveMapper->findByIdAndUser($share->getCollectiveId())) { + throw new NotFoundException('Unable to find a collective from its share token'); + } + + $collective->setShareToken($share->getToken()); + $collective->setIsPageShare($share->getPageId() !== 0); + $collective->setSharePageId($share->getPageId()); + return $collective; + } + public function getCollectiveNameWithEmoji(Collective $collective): string { $emoji = $collective->getEmoji(); return $emoji diff --git a/lib/Service/CollectiveShareService.php b/lib/Service/CollectiveShareService.php index 4f1e24fb9..a4e6587f2 100644 --- a/lib/Service/CollectiveShareService.php +++ b/lib/Service/CollectiveShareService.php @@ -120,13 +120,17 @@ public function findShare(string $userId, int $collectiveId, int $pageId): ?Coll return $collectiveShare; } - public function findShareByToken(string $token): ?CollectiveShare { + public function findShareByToken(string $token, bool $getPermissionInfo = true): ?CollectiveShare { try { $collectiveShare = $this->collectiveShareMapper->findOneByToken($token); } catch (DoesNotExistException | MultipleObjectsReturnedException | Exception) { return null; } + if (!$getPermissionInfo) { + return $collectiveShare; + } + try { $folderShare = $this->shareManager->getShareByToken($collectiveShare->getToken()); } catch (ShareNotFound) { diff --git a/lib/Service/PageService.php b/lib/Service/PageService.php index 362eae9d9..2a3f15b29 100644 --- a/lib/Service/PageService.php +++ b/lib/Service/PageService.php @@ -497,8 +497,10 @@ public function findByString(int $collectiveId, string $search, string $userId): * @throws NotFoundException * @throws NotPermittedException */ - public function findByPath(int $collectiveId, string $path, string $userId): PageInfo { - $collectiveFolder = $this->getCollectiveFolder($collectiveId, $userId); + public function findByPath(int $collectiveId, string $path, string $userId, ?int $parentId = null): PageInfo { + $collectiveFolder = $parentId + ? $this->getFolder($collectiveId, $parentId, $userId) + : $this->getCollectiveFolder($collectiveId, $userId); $landingPageId = $this->getIndexPageFile($collectiveFolder)->getId(); if ($path === '' || $path === '/') { @@ -531,8 +533,10 @@ public function findByPath(int $collectiveId, string $path, string $userId): Pag * @throws NotFoundException * @throws NotPermittedException */ - public function findByFileId(int $collectiveId, int $fileId, string $userId): PageInfo { - $collectiveFolder = $this->getCollectiveFolder($collectiveId, $userId); + public function findByFileId(int $collectiveId, int $fileId, string $userId, ?int $parentId = null): PageInfo { + $collectiveFolder = $parentId + ? $this->getFolder($collectiveId, $parentId, $userId) + : $this->getCollectiveFolder($collectiveId, $userId); $pageFile = $collectiveFolder->getById($fileId); if (isset($pageFile[0]) && $pageFile[0] instanceof File) { $pageFile = $pageFile[0]; diff --git a/lib/Service/SharePageService.php b/lib/Service/SharePageService.php new file mode 100644 index 000000000..75cd1939c --- /dev/null +++ b/lib/Service/SharePageService.php @@ -0,0 +1,38 @@ +shareService->findShareByToken($shareToken, false)) { + throw new NotFoundException('Page or share not found'); + } + $parentId = $collectiveShare->getPageId() === 0 ? null : $collectiveShare->getPageId(); + return $this->pageService->findByFileId($collectiveShare->getCollectiveId(), $pageId, $collectiveShare->getOwner(), $parentId); + } + + /** + * @throws NotFoundException + */ + public function findSharePageByPath(string $shareToken, string $path): PageInfo { + // Don't decorate share with editing permissions (for performance reasons) + if (null === $collectiveShare = $this->shareService->findShareByToken($shareToken, false)) { + throw new NotFoundException('Page or share not found'); + } + $parentId = $collectiveShare->getPageId() === 0 ? null : $collectiveShare->getPageId(); + return $this->pageService->findByPath($collectiveShare->getCollectiveId(), $path, $collectiveShare->getOwner(), $parentId); + } +} diff --git a/src/Collectives.vue b/src/Collectives.vue index be83746d3..0bd2b3554 100644 --- a/src/Collectives.vue +++ b/src/Collectives.vue @@ -1,5 +1,6 @@