From ad14083e984a77f2dd566f24767f67f3c257295f 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 --- cypress/e2e/pages-links.spec.js | 107 +++++++ lib/AppInfo/Application.php | 8 +- lib/Db/Collective.php | 12 +- .../SearchablePageReferenceProvider.php | 271 +++++++++++------- .../SearchablePageReferenceProvider29.php | 231 +++++++++++++++ lib/Service/CollectiveService.php | 23 ++ lib/Service/CollectiveShareService.php | 6 +- lib/Service/PageService.php | 12 +- lib/Service/SharePageService.php | 39 +++ src/Collectives.vue | 5 + .../SearchablePageReferenceProviderTest.php | 123 ++++++++ tests/Unit/Service/CollectiveServiceTest.php | 1 + tests/phpunit.xml | 1 + tests/psalm-baseline.xml | 3 +- 14 files changed, 737 insertions(+), 105 deletions(-) create mode 100644 lib/Reference/SearchablePageReferenceProvider29.php create mode 100644 lib/Service/SharePageService.php create mode 100644 tests/Unit/Search/SearchablePageReferenceProviderTest.php diff --git a/cypress/e2e/pages-links.spec.js b/cypress/e2e/pages-links.spec.js index 47b2ac8dd..8771bb692 100644 --- a/cypress/e2e/pages-links.spec.js +++ b/cypress/e2e/pages-links.spec.js @@ -598,3 +598,110 @@ describe('Page link handling', function() { } }) }) + +// Previews got added with Nextcloud 29 +if (!['stable27', 'stable28'].includes(Cypress.env('ncVersion'))) { + describe('Page link preview handling', function() { + before(function() { + cy.loginAs('bob') + cy.deleteAndSeedCollective('Link Preview Testing') + .seedPage('Link Source', '', 'Readme.md') + .seedPage('Link Target', '', 'Readme.md') + .then(({ pageId }) => { + const pageUrls = [ + `${baseUrl}/index.php/apps/collectives/Link%20Preview%20Testing/Link%20Target?fileId=${pageId}`, + `${baseUrl}/index.php/apps/collectives/Link%20Preview%20Testing/Link%20Target`, + `${baseUrl}/index.php/apps/collectives/p/qqqYoCgYRnZ598p/Link%20Preview%20Testing/Link%20Target?fileId=${pageId}`, + `${baseUrl}/index.php/apps/collectives/p/qqqYoCgYRnZ598p/Link%20Preview%20Testing/Link%20Target`, + ] + cy.seedPageContent('Link%20Preview%20Testing/Link%20Target.md', 'Some content') + .seedPageContent('Link%20Preview%20Testing/Link%20Source.md', ` +## Link previews to own Collective + +[Internal link to page with fileId](${pageUrls[0]} (preview)) + +[Internal link to page without fileId](${pageUrls[1]} (preview)) + +[Public link to page with fileId](${pageUrls[2]} (preview)) + +[Public link to page without fileId](${pageUrls[3]} (preview)) + `) + + }) + }) + + beforeEach(function() { + cy.loginAs('bob') + cy.visit('/apps/collectives/Link Preview Testing/Link Source') + // make sure the page list loaded properly + cy.contains('.app-content-list-item a', 'Link Target') + }) + + it('Shows previews in view and edit mode', function() { + cy.getEditorContent() + .find('.widget-custom a.collective-page') + .should('have.length', 4) + + cy.switchToEditMode() + cy.getEditorContent(true) + .find('.widget-custom a.collective-page') + .should('have.length', 4) + }) + + // Previews in public shares got added with Nextcloud 30 + if (!['stable29'].includes(Cypress.env('ncVersion'))) { + let shareUrl + + it('Share the collective', function() { + cy.visit('/apps/collectives', { + onBeforeLoad(win) { + // navigator.clipboard doesn't exist on HTTP requests (in CI), so let's create it + if (!win.navigator.clipboard) { + win.navigator.clipboard = { + __proto__: { + writeText: () => { + }, + }, + } + } + // overwrite navigator.clipboard.writeText with cypress stub + cy.stub(win.navigator.clipboard, 'writeText', (text) => { + shareUrl = text + }) + .as('clipBoardWriteText') + }, + }) + cy.openCollectiveMenu('Link Preview Testing') + cy.clickMenuButton('Share link') + cy.intercept('POST', '**/_api/*/share').as('createShare') + cy.get('.sharing-entry button.new-share-link') + .click() + cy.wait('@createShare') + cy.get('.sharing-entry .share-select') + .click() + cy.intercept('PUT', '**/_api/*/share/*').as('updateShare') + cy.get('.sharing-entry .share-select .dropdown-item') + .contains('Can edit') + .click() + cy.wait('@updateShare') + cy.get('button.sharing-entry__copy') + .click() + cy.get('@clipBoardWriteText').should('have.been.calledOnce') + }) + + it('Public share: Shows previews in view and edit mode', function() { + cy.logout() + cy.visit(`${shareUrl}/Link Source`) + + cy.getEditorContent() + .find('.widget-custom a.collective-page') + .should('have.length', 4) + + cy.switchToEditMode() + cy.getEditorContent(true) + .find('.widget-custom a.collective-page') + .should('have.length', 4) + }) + } + }) +} diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index b8fb8da0b..e8d89b0bf 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -18,6 +18,7 @@ use OCA\Collectives\Mount\CollectiveFolderManager; use OCA\Collectives\Mount\MountProvider; use OCA\Collectives\Reference\SearchablePageReferenceProvider; +use OCA\Collectives\Reference\SearchablePageReferenceProvider29; use OCA\Collectives\Search\CollectiveProvider; use OCA\Collectives\Search\PageContentProvider; use OCA\Collectives\Search\PageProvider; @@ -36,6 +37,7 @@ use OCP\AppFramework\Bootstrap\IRegistrationContext; use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Collaboration\Reference\IPublicReferenceProvider; use OCP\Collaboration\Reference\RenderReferenceEvent; use OCP\Dashboard\IAPIWidgetV2; use OCP\Files\Config\IMountProviderCollection; @@ -98,7 +100,11 @@ public function register(IRegistrationContext $context): void { $context->registerSearchProvider(PageProvider::class); $context->registerSearchProvider(PageContentProvider::class); - $context->registerReferenceProvider(SearchablePageReferenceProvider::class); + if (interface_exists(IPublicReferenceProvider::class)) { + $context->registerReferenceProvider(SearchablePageReferenceProvider::class); + } else { + $context->registerReferenceProvider(SearchablePageReferenceProvider29::class); + } $cacheListener = $this->getContainer()->get(CacheListener::class); $cacheListener->listen(); 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..bc0e0d12a 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,17 +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 { @@ -57,125 +63,198 @@ 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); + } - $collectiveName = $pageReferenceInfo['collectiveName']; + /** + * @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'); + } + + 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); + } + 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); } - return null; + $pageReferenceInfo['collective'] = $collective; + $pageReferenceInfo['page'] = $page; + + $collectivesLink = $this->urlGenerator->linkToRouteAbsolute('collectives.start.index') . ($public ? 'p/' . $sharingToken . '/' : ''); + $link = $collectivesLink . $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/Reference/SearchablePageReferenceProvider29.php b/lib/Reference/SearchablePageReferenceProvider29.php new file mode 100644 index 000000000..d7c4f7982 --- /dev/null +++ b/lib/Reference/SearchablePageReferenceProvider29.php @@ -0,0 +1,231 @@ +l10n->t('Collective pages'); + } + + public function getOrder(): int { + return 10; + } + + public function getIconUrl(): string { + return $this->urlGenerator->getAbsoluteURL( + $this->urlGenerator->imagePath(Application::APP_NAME, 'collectives-dark.svg') + ); + } + + public function getSupportedSearchProviderIds(): array { + return ['collectives-pages']; + } + + 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); + } + } + + // link examples: + // https://nextcloud.local/apps/collectives/supacollective/Tutos/Hacking/Spectre?fileId=14457 + // https://nextcloud.local/apps/collectives/supacollective/Tutos/Hacking/Spectre + $startRegexes = [ + $this->urlGenerator->getAbsoluteURL('/apps/' . Application::APP_NAME), + $this->urlGenerator->getAbsoluteURL('/index.php/apps/' . Application::APP_NAME), + ]; + + 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 matchReference(string $referenceText): bool { + return (bool)$this->matchUrl($referenceText); + } + + /** + * @throws NotFoundException + */ + private function getCollective(string $collectiveName): Collective { + return $this->collectiveService->findCollectiveByName($this->userId, $collectiveName); + } + + /** + * @throws NotFoundException + */ + private function getPage(Collective $collective, array $pageReferenceInfo): PageInfo { + 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); + } + } else { + throw new NotFoundException('Pathinfo for page path is incomplete'); + } + } + } + + return $page; + } + + private function resolve(string $referenceText): ?IReference { + if (!$this->matchReference($referenceText)) { + return null; + } + + $pageReferenceInfo = $this->getPagePathFromDirectLink($referenceText); + if (!$pageReferenceInfo) { + // fallback to opengraph if it matches, but somehow we can't resolve + return $this->linkReferenceProvider->resolveReference($referenceText); + } + + $collectiveName = $pageReferenceInfo['collectiveName']; + + try { + $collective = $this->getCollective($collectiveName); + $page = $this->getPage($collective, $pageReferenceInfo); + } 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; + + $collectivesLink = $this->urlGenerator->linkToRouteAbsolute('collectives.start.index'); + $link = $collectivesLink . $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 getPagePathFromDirectLink(string $url): ?array { + return $this->matchUrl($url); + } + + public function getCachePrefix(string $referenceId): string { + return $referenceId; + } + + public function getCacheKey(string $referenceId): ?string { + return $this->userId ?? ''; + } + + public function invalidateUserCache(string $userId): void { + $this->referenceManager->invalidateCache($userId); + } +} diff --git a/lib/Service/CollectiveService.php b/lib/Service/CollectiveService.php index 209c1c6ab..14d0ca1aa 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,28 @@ 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'); + } + + $circle = $this->circleHelper->getCircle($collective->getCircleId(), null, true); + $collective->setName($circle->getSanitizedName()); + $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..ec2902686 --- /dev/null +++ b/lib/Service/SharePageService.php @@ -0,0 +1,39 @@ +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 9f1b4cfb3..2e3dbdd2f 100644 --- a/src/Collectives.vue +++ b/src/Collectives.vue @@ -1,5 +1,9 @@